package main import ( "bytes" "encoding/base64" "encoding/json" "errors" encrypt "fckeuspy-go/lib" "fmt" "image" "image/color" "image/png" "io" "os" "os/exec" "path/filepath" "sort" "strings" "sync/atomic" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) // --- Core UI model --- type uiParts struct { outKey, msg, peer, cipherOut, payload, plainOut *widget.Entry toastLabel *widget.Label cipherQR, pubQR, crtQR, peerQR, payloadQR *canvas.Image showQR, showPeerQR, showPayloadQR bool } func buildEntries() *uiParts { p := &uiParts{ outKey: widget.NewMultiLineEntry(), msg: widget.NewMultiLineEntry(), peer: widget.NewMultiLineEntry(), cipherOut: widget.NewMultiLineEntry(), payload: widget.NewMultiLineEntry(), plainOut: widget.NewMultiLineEntry(), toastLabel: widget.NewLabel(""), cipherQR: canvas.NewImageFromImage(nil), pubQR: canvas.NewImageFromImage(nil), crtQR: canvas.NewImageFromImage(nil), peerQR: canvas.NewImageFromImage(nil), payloadQR: canvas.NewImageFromImage(nil), showQR: true, showPeerQR: true, showPayloadQR: true, } p.cipherQR.SetMinSize(fyne.NewSize(220, 220)) p.pubQR.SetMinSize(fyne.NewSize(200, 200)) p.peerQR.SetMinSize(fyne.NewSize(200, 200)) p.payloadQR.SetMinSize(fyne.NewSize(220, 220)) p.pubQR.FillMode = canvas.ImageFillContain p.toastLabel.Hide() return p } func (p *uiParts) showToast(s string) { fyne.Do(func() { p.toastLabel.SetText(s); p.toastLabel.Show() }) time.AfterFunc(1500*time.Millisecond, func() { fyne.Do(func() { if p.toastLabel.Text == s { p.toastLabel.Hide() } }) }) } // Theme type simpleTheme struct{} var ( chatIncomingBubbleColor = color.NRGBA{39, 48, 59, 255} chatOutgoingBubbleColor = color.NRGBA{28, 84, 113, 255} chatFailedBubbleColor = color.NRGBA{91, 48, 48, 255} ) func (simpleTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { switch n { case theme.ColorNameBackground: return color.NRGBA{18, 22, 28, 255} case theme.ColorNameInputBackground: return color.NRGBA{29, 35, 43, 255} case theme.ColorNameButton: return color.NRGBA{39, 48, 59, 255} case theme.ColorNamePrimary: return color.NRGBA{99, 179, 237, 255} case theme.ColorNameForeground: return color.NRGBA{235, 240, 245, 255} case theme.ColorNameDisabled: // Make disabled text brighter for readability on dark background return color.NRGBA{230, 233, 238, 255} default: return theme.DefaultTheme().Color(n, v) } } func (simpleTheme) Font(st fyne.TextStyle) fyne.Resource { return theme.DefaultTheme().Font(st) } func (simpleTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(n) } func (simpleTheme) Size(n fyne.ThemeSizeName) float32 { return theme.DefaultTheme().Size(n) } // Facade interface type ServiceFacade interface { Encrypt(msg, peer string) (string, error) EncryptChat(msg, peer string) (string, error) Decrypt(json string) (string, error) DecryptChat(json string) (DecryptedChatMessage, error) PublicPEM() string PublicCert() string ListContacts() ([]Contact, error) SaveContact(c Contact) error DeleteContact(id string) error ListChatMessages() ([]ChatMessage, error) AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error) AppendScannedChatMessage(direction, text, payload string, createdAt time.Time) (ChatMessage, bool, error) } // Clipboard helpers func copyClip(s string, parts *uiParts) { fyne.CurrentApp().Clipboard().SetContent(s) parts.showToast("Zkopírováno") } func copyImageToClipboard(img image.Image, parts *uiParts) { if img == nil { return } buf := &bytes.Buffer{} if err := png.Encode(buf, img); err != nil { parts.showToast("Chyba PNG") return } choose := func() *exec.Cmd { wayland := os.Getenv("WAYLAND_DISPLAY") != "" has := func(b string) bool { _, e := exec.LookPath(b); return e == nil } if wayland { if has("wl-copy") { return exec.Command("wl-copy", "--type", "image/png") } if has("xclip") { return exec.Command("xclip", "-selection", "clipboard", "-t", "image/png") } } else { if has("xclip") { return exec.Command("xclip", "-selection", "clipboard", "-t", "image/png") } if has("wl-copy") { return exec.Command("wl-copy", "--type", "image/png") } } return nil }() if choose == nil { parts.showToast("Chybí wl-copy/xclip") return } stdin, _ := choose.StdinPipe() if err := choose.Start(); err != nil { parts.showToast("Nelze spustit") return } _, _ = stdin.Write(buf.Bytes()) _ = stdin.Close() if err := choose.Wait(); err != nil { parts.showToast("Selhalo") return } parts.showToast("QR obrázek ve schránce") } func readImageClipboard() (image.Image, error) { has := func(b string) bool { _, e := exec.LookPath(b); return e == nil } tryDecode := func(d []byte) (image.Image, bool) { if len(d) == 0 { return nil, false } if img, _, e := image.Decode(bytes.NewReader(d)); e == nil { return img, true } s := strings.TrimSpace(string(d)) if strings.HasPrefix(s, "data:image") { if p := strings.Index(s, ","); p > 0 { s = s[p+1:] } } if raw, err := base64.StdEncoding.DecodeString(s); err == nil { if img, _, e2 := image.Decode(bytes.NewReader(raw)); e2 == nil { return img, true } } return nil, false } if os.Getenv("WAYLAND_DISPLAY") != "" && has("wl-paste") { if types, err := exec.Command("wl-paste", "--list-types").Output(); err == nil { pref := []string{"image/png", "image/jpeg", "image/jpg", "image/webp"} seen := map[string]bool{} for _, p := range pref { seen[p] = true } order := append([]string{}, pref...) for _, t := range strings.Split(string(types), "\n") { t = strings.TrimSpace(t) if t == "" || !strings.HasPrefix(t, "image/") { continue } if !seen[t] { order = append(order, t) } } for _, t := range order { if data, err := exec.Command("wl-paste", "--type", t).Output(); err == nil { if img, ok := tryDecode(data); ok { return img, nil } } } } if data, err := exec.Command("wl-paste").Output(); err == nil { if img, ok := tryDecode(data); ok { return img, nil } } } if has("xclip") { for _, m := range []string{"image/png", "image/jpeg", "image/jpg", "image/bmp"} { if data, err := exec.Command("xclip", "-selection", "clipboard", "-t", m, "-o").Output(); err == nil { if img, ok := tryDecode(data); ok { return img, nil } } } if data, err := exec.Command("xclip", "-selection", "clipboard", "-o").Output(); err == nil { if img, ok := tryDecode(data); ok { return img, nil } } } return nil, errors.New("nenalezen žádný obrázek ve schránce") } // Identity tab func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { // Toolbar: choose what to encode into the QR to keep density manageable mode := "cert" // cert, pub options := []string{"Certifikát", "Veřejný klíč"} chooser := widget.NewSelect(options, func(string) {}) chooser.Selected = options[0] var update func() chooser.OnChanged = func(v string) { switch v { case "Certifikát": mode = "cert" case "Veřejný klíč": mode = "pub" default: mode = "cert" } update() } deleteBtn := widget.NewButton("Smazat identitu", func() { pw := widget.NewPasswordEntry() form := widget.NewForm(widget.NewFormItem("Heslo", pw)) warn := widget.NewLabel("Smazat vše?") d := dialog.NewCustomConfirm("Potvrdit smazání", "Smazat", "Zrušit", container.NewVBox(warn, form), func(ok bool) { if !ok { return } if _, err := encrypt.OpenEncryptedStore(vaultPath, pw.Text); err != nil { dialog.NewError(errors.New("neplatné heslo"), fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } _ = os.Remove(vaultPath) fyne.CurrentApp().Quit() }, fyne.CurrentApp().Driver().AllWindows()[0]) d.Resize(fyne.NewSize(420, 200)) d.Show() }) // Keep button minimal; align right deleteRow := container.NewHBox(layout.NewSpacer(), deleteBtn) makeQR := func(data string, target *canvas.Image) { if data == "" { target.Image = nil target.Refresh() return } if b, err := GenerateQRPNG(data, 512); err == nil { if im, err2 := LoadPNG(b); err2 == nil { target.Image = im target.Refresh() } } } update = func() { if parts.showQR { var text string switch mode { case "pub": text = strings.TrimSpace(svc.PublicPEM()) default: // cert text = strings.TrimSpace(svc.PublicCert()) } makeQR(text, parts.pubQR) } else { parts.pubQR.Image = nil parts.pubQR.Refresh() } } update() saveQR := widget.NewButtonWithIcon("Uložit QR", theme.DocumentSaveIcon(), func() { if parts.pubQR.Image == nil { return } win := fyne.CurrentApp().Driver().AllWindows()[0] img := parts.pubQR.Image fd := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) { if err != nil || wc == nil { return } defer wc.Close() _ = png.Encode(wc, img) parts.showToast("QR uložen") }, win) fd.SetFileName("identity_qr.png") fd.Show() }) qrRow := container.NewHBox( widget.NewLabel("Obsah QR"), chooser, layout.NewSpacer(), widget.NewButtonWithIcon("Kopírovat jako obrázek", theme.ContentPasteIcon(), func() { copyImageToClipboard(parts.pubQR.Image, parts) }), saveQR, ) box := container.NewVBox(qrRow, container.NewCenter(parts.pubQR)) return container.NewVScroll(container.NewVBox(container.NewHBox(widget.NewLabelWithStyle("Moje identita", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer()), box, deleteRow)) } // Decrypt tab func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { parts.plainOut.Disable() parts.plainOut.Wrapping = fyne.TextWrapWord parts.plainOut.SetMinRowsVisible(12) parts.payloadQR.FillMode = canvas.ImageFillContain parts.payloadQR.SetMinSize(fyne.NewSize(260, 260)) decrypt := func(text string) { trimmed := strings.TrimSpace(text) if trimmed == "" { parts.plainOut.SetText("") return } go func(j string) { res, err := svc.Decrypt(j) if err != nil { fyne.Do(func() { parts.plainOut.SetText("") parts.showToast("Chyba dešifrování") }) return } fyne.Do(func() { parts.plainOut.SetText(res) }) }(trimmed) } setPayload := func(cipher string, img image.Image) { parts.payloadQR.Image = img parts.payloadQR.Refresh() decrypt(cipher) } decodeFromImage := func(img image.Image) { if img == nil { parts.showToast("Žádný QR obrázek") return } txt, err := DecodeQR(img) if err != nil { bounds := img.Bounds() inv := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { r, g, b, a := img.At(x, y).RGBA() inv.Set(x, y, color.RGBA{uint8(255 - r/257), uint8(255 - g/257), uint8(255 - b/257), uint8(a / 257)}) } } if txt2, err2 := DecodeQR(inv); err2 == nil { setPayload(txt2, img) parts.showToast("Načteno z invert QR") return } parts.showToast("QR nenalezen: " + err.Error()) return } setPayload(txt, img) parts.showToast("Načteno z QR") } pasteQRBtn := widget.NewButtonWithIcon("Vložit ze schránky", theme.ContentPasteIcon(), func() { img, err := readImageClipboard() if err != nil { parts.showToast("Chyba schránky: " + err.Error()) return } decodeFromImage(img) }) openQRBtn := widget.NewButtonWithIcon("Otevřít obrázek", theme.FolderOpenIcon(), func() { win := fyne.CurrentApp().Driver().AllWindows()[0] fd := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) { if err != nil || rc == nil { return } defer rc.Close() data, readErr := io.ReadAll(rc) if readErr != nil { parts.showToast("Chyba čtení souboru") return } img, _, decErr := image.Decode(bytes.NewReader(data)) if decErr != nil { parts.showToast("Neplatný obrázek: " + decErr.Error()) return } decodeFromImage(img) }, win) fd.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"})) fd.Show() }) clearBtn := widget.NewButtonWithIcon("Vymazat", theme.ContentClearIcon(), func() { parts.payloadQR.Image = nil parts.payloadQR.Refresh() parts.plainOut.SetText("") parts.showToast("Vymazáno") }) // Align buttons to the right by placing spacer first toolbar := container.NewHBox(layout.NewSpacer(), pasteQRBtn, openQRBtn, clearBtn) copyDecBtn := widget.NewButtonWithIcon("Kopírovat zprávu", theme.ContentCopyIcon(), func() { if strings.TrimSpace(parts.plainOut.Text) != "" { copyClip(parts.plainOut.Text, parts) } }) return container.NewVBox( widget.NewLabelWithStyle("Dešifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), toolbar, container.NewHBox(parts.payloadQR, layout.NewSpacer()), container.NewHBox(widget.NewLabel("Výsledek"), layout.NewSpacer(), copyDecBtn), parts.plainOut, ) } // Per-contact encryption popup (QR-only output) func openEncryptPopup(parts *uiParts, svc ServiceFacade, ct Contact) { msgEntry := widget.NewMultiLineEntry() msgEntry.SetMinRowsVisible(6) msgEntry.Wrapping = fyne.TextWrapWord status := widget.NewLabel("Zadej zprávu…") qrImg := canvas.NewImageFromImage(nil) qrImg.FillMode = canvas.ImageFillContain qrImg.SetMinSize(fyne.NewSize(300, 300)) updateQR := func(text string) { if strings.TrimSpace(text) == "" { qrImg.Image = nil qrImg.Refresh() return } if b, err := GenerateQRPNG(text, 512); err == nil { if im, err2 := LoadPNG(b); err2 == nil { qrImg.Image = im qrImg.Refresh() } } } win := fyne.CurrentApp().Driver().AllWindows()[0] doEncrypt := func() { m := strings.TrimSpace(msgEntry.Text) fyne.Do(func() { if m == "" { status.SetText("Zpráva je prázdná") updateQR("") return } status.SetText("Šifruji…") }) if m == "" { return } go func(txt string) { res, err := svc.Encrypt(txt, ct.Cert) if err != nil { fyne.Do(func() { status.SetText("Chyba: " + err.Error()) }) return } fyne.Do(func() { updateQR(res); status.SetText("Hotovo") }) }(m) } var tmr *time.Timer msgEntry.OnChanged = func(string) { if tmr != nil { tmr.Stop() } tmr = time.AfterFunc(300*time.Millisecond, doEncrypt) } copyQRBtn := widget.NewButton("Kopírovat QR", func() { copyImageToClipboard(qrImg.Image, parts) }) saveQRBtn := widget.NewButton("Uložit QR", func() { if qrImg.Image == nil { return } img := qrImg.Image fd := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) { if err != nil || wc == nil { return } defer wc.Close() _ = png.Encode(wc, img) status.SetText("QR uložen") }, win) fd.SetFileName("message_qr.png") fd.Show() }) content := container.NewVBox(widget.NewLabel("Zpráva"), msgEntry, widget.NewSeparator(), container.NewHBox(widget.NewLabel("QR kód"), layout.NewSpacer(), copyQRBtn, saveQRBtn), qrImg, status) title := ct.Name if title == "" { title = "(bez názvu)" } if cn := extractCN(ct.Cert); cn != "" && !strings.Contains(title, cn) { title = fmt.Sprintf("%s (%s)", title, cn) } dlg := dialog.NewCustom(fmt.Sprintf("Poslat zprávu: %s", title), "Zavřít", content, win) dlg.Resize(fyne.NewSize(640, 520)) dlg.Show() } // Contacts tab with QR-only popup func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { var all, filtered []Contact load := func() { items, _ := svc.ListContacts(); all = items; filtered = items } apply := func(q string) { if q == "" { filtered = all return } low := strings.ToLower(q) tmp := make([]Contact, 0, len(all)) for _, c := range all { if strings.Contains(strings.ToLower(c.Name), low) || strings.Contains(strings.ToLower(c.Cert), low) { tmp = append(tmp, c) } } filtered = tmp } makeDefault := func() string { base := "Nový kontakt" exists := false maxN := 1 for _, c := range all { if c.Name == base { exists = true } if strings.HasPrefix(c.Name, base+" ") { var n int if _, err := fmt.Sscanf(c.Name, "Nový kontakt %d", &n); err == nil && n >= maxN { maxN = n + 1 } } } if !exists { return base } return fmt.Sprintf("%s %d", base, maxN) } search := widget.NewEntry() var list *widget.List openPopup := func(existing *Contact) { nameEntry := widget.NewEntry() if existing != nil { nameEntry.SetText(existing.Name) } else { nameEntry.SetText(makeDefault()) } var certValue string if existing != nil { certValue = existing.Cert } qrImg := canvas.NewImageFromImage(nil) qrImg.FillMode = canvas.ImageFillContain qrImg.SetMinSize(fyne.NewSize(300, 300)) updateQR := func() { if strings.TrimSpace(certValue) == "" { qrImg.Image = nil qrImg.Refresh() return } if b, err := GenerateQRPNG(certValue, 512); err == nil { if im, err2 := LoadPNG(b); err2 == nil { qrImg.Image = im qrImg.Refresh() } } } updateQR() pasteQR := widget.NewToolbarAction(theme.ContentPasteIcon(), func() { img, err := readImageClipboard() if err != nil { parts.showToast("Chyba čtení schránky: " + err.Error()) return } if img == nil { parts.showToast("Žádný QR obrázek") return } txt, decErr := DecodeQR(img) if decErr != nil { bounds := img.Bounds() inv := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { r, g, b, a := img.At(x, y).RGBA() inv.Set(x, y, color.RGBA{uint8(255 - r/257), uint8(255 - g/257), uint8(255 - b/257), uint8(a / 257)}) } } if txt2, err2 := DecodeQR(inv); err2 == nil { certValue = txt2 updateQR() parts.showToast("Načteno z invert QR") return } debugDir := "qr_debug" _ = os.MkdirAll(debugDir, 0o755) fp := filepath.Join(debugDir, fmt.Sprintf("qr_clip_%d.png", time.Now().UnixNano())) if f, e := os.Create(fp); e == nil { _ = png.Encode(f, img) _ = f.Close() parts.showToast("QR nenalezen: " + decErr.Error() + " (" + fp + ")") } else { parts.showToast("QR nenalezen: " + decErr.Error()) } return } certValue = txt updateQR() parts.showToast("Načteno z QR") }) openImg := widget.NewToolbarAction(theme.FolderOpenIcon(), func() { win := fyne.CurrentApp().Driver().AllWindows()[0] fd := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) { if err != nil || rc == nil { return } defer rc.Close() data, _ := io.ReadAll(rc) img, _, e2 := image.Decode(bytes.NewReader(data)) if e2 != nil { parts.showToast("Neplatný obrázek: " + e2.Error()) return } txt, e3 := DecodeQR(img) if e3 != nil { parts.showToast("QR nenalezeno: " + e3.Error()) return } certValue = txt updateQR() parts.showToast("Načteno z QR") }, win) fd.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"})) fd.Show() }) clearAct := widget.NewToolbarAction(theme.ContentClearIcon(), func() { certValue = ""; updateQR() }) toolbar := widget.NewToolbar(pasteQR, openImg, clearAct) win := fyne.CurrentApp().Driver().AllWindows()[0] var popup dialog.Dialog save := func(useEncrypt bool) { name := strings.TrimSpace(nameEntry.Text) cert := strings.TrimSpace(certValue) if cert == "" { parts.showToast("Chybí cert") return } cn := extractCN(cert) ask := cn != "" && (name == "" || name == "Kontakt" || name == "Nový kontakt" || name != cn) proceed := func(final string) { if final == "" || final == "Kontakt" || final == "Nový kontakt" { final = makeDefault() } if existing == nil { _ = svc.SaveContact(Contact{Name: final, Cert: cert}) } else { c := *existing c.Name = final c.Cert = cert _ = svc.SaveContact(c) } load() apply(strings.TrimSpace(search.Text)) list.Refresh() parts.showToast("Uloženo") if useEncrypt { parts.peer.SetText(cert) } if popup != nil { popup.Hide() } } if ask { entry := widget.NewEntry() entry.SetText(name) content := container.NewVBox( widget.NewLabel(fmt.Sprintf("Common Name nalezen v certifikátu: %s", cn)), widget.NewLabel("Chcete použít CN jako název, nebo jej upravit?"), entry, ) dialog.NewCustomConfirm("Název kontaktu", "Použít CN", "Uložit", content, func(ok bool) { if ok { proceed(cn) return } proceed(strings.TrimSpace(entry.Text)) }, win).Show() return } proceed(name) } delBtn := widget.NewButtonWithIcon("Smazat", theme.DeleteIcon(), func() { if existing == nil { popup.Hide() return } dialog.NewConfirm("Smazat", "Opravdu smazat?", func(ok bool) { if !ok { return } _ = svc.DeleteContact(existing.ID) load() apply(strings.TrimSpace(search.Text)) list.Refresh() popup.Hide() parts.showToast("Smazáno") }, win).Show() }) saveBtn := widget.NewButton("Uložit", func() { save(false) }) row := container.NewHBox(layout.NewSpacer(), saveBtn, delBtn, layout.NewSpacer()) title := "Nový kontakt" if existing != nil { title = "Upravit kontakt" } // manual entry area below QR for fallback or direct edit popup = dialog.NewCustom(title, "Zavřít", container.NewVBox( widget.NewLabel("Název"), nameEntry, widget.NewLabel("Certifikát / Public key (QR)"), toolbar, qrImg, widget.NewSeparator(), row), win) popup.Resize(fyne.NewSize(640, 520)) popup.Show() } list = widget.NewList(func() int { return len(filtered) }, func() fyne.CanvasObject { lbl := widget.NewLabel("") msg := widget.NewButton("Zpráva", nil) edit := widget.NewButton("Upravit", nil) msg.Importance = widget.LowImportance edit.Importance = widget.LowImportance return container.NewBorder(nil, nil, lbl, container.NewHBox(msg, edit)) }, func(i widget.ListItemID, o fyne.CanvasObject) { if int(i) < 0 || int(i) >= len(filtered) { return } c := filtered[i] row := o.(*fyne.Container) lbl := row.Objects[0].(*widget.Label) btnBox := row.Objects[1].(*fyne.Container) msgBtn := btnBox.Objects[0].(*widget.Button) editBtn := btnBox.Objects[1].(*widget.Button) name := c.Name if name == "" { name = "(bez názvu)" } if cn := extractCN(c.Cert); cn != "" { lbl.SetText(fmt.Sprintf("%s (%s)", name, cn)) } else { lbl.SetText(name) } msgBtn.OnTapped = func() { openEncryptPopup(parts, svc, c) } editBtn.OnTapped = func() { var ptr *Contact for i := range all { if all[i].ID == c.ID { ptr = &all[i] break } } openPopup(ptr) } }) search.SetPlaceHolder("Hledat…") search.OnChanged = func(s string) { apply(s); list.Refresh() } addBtn := widget.NewButtonWithIcon("Přidat", theme.ContentAddIcon(), func() { openPopup(nil) }) header := container.NewHBox(widget.NewLabelWithStyle("Kontakty", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), addBtn) load() list.Refresh() return container.NewBorder(header, nil, nil, nil, container.NewBorder(search, nil, nil, nil, list)) } type chatScanResult struct { direction string payload string plaintext string err string createdAt time.Time } const ( chatUIDirectionIncoming = "incoming" chatUIDirectionOutgoing = "outgoing" ) func chatBubbleTitle(item chatScanResult) string { if item.err != "" { return "Zprávu se nepodařilo dešifrovat" } timestamp := "" if !item.createdAt.IsZero() { timestamp = " · " + item.createdAt.Local().Format("15:04") } if item.direction == chatUIDirectionOutgoing { return "Odchozí zpráva" + timestamp } return "Příchozí zpráva" + timestamp } func chatBubbleBody(item chatScanResult) string { if item.err != "" { return item.err } return item.plaintext } func chatBubbleColor(item chatScanResult) color.Color { if item.err != "" { return chatFailedBubbleColor } if item.direction == chatUIDirectionOutgoing { return chatOutgoingBubbleColor } return chatIncomingBubbleColor } func chatBubbleHeight(item chatScanResult) float32 { lineCount := 1 lineLen := 0 addLine := func() { wrapped := (lineLen + 71) / 72 lineCount += max(wrapped, 1) lineLen = 0 } for _, r := range chatBubbleBody(item) { if r == '\n' { addLine() continue } lineLen++ } addLine() return float32(42 + lineCount*22) } func newChatBubbleRow() fyne.CanvasObject { background := canvas.NewRectangle(chatIncomingBubbleColor) background.CornerRadius = 12 title := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) title.Wrapping = fyne.TextWrapWord body := widget.NewLabel("") body.Wrapping = fyne.TextWrapWord return container.NewStack(background, container.NewPadded(container.NewVBox(title, body))) } func updateChatBubbleRow(obj fyne.CanvasObject, item chatScanResult) { bubble, ok := obj.(*fyne.Container) if !ok || len(bubble.Objects) < 2 { return } background, ok := bubble.Objects[0].(*canvas.Rectangle) if !ok { return } padded, ok := bubble.Objects[1].(*fyne.Container) if !ok || len(padded.Objects) < 1 { return } content, ok := padded.Objects[0].(*fyne.Container) if !ok || len(content.Objects) < 2 { return } title, ok := content.Objects[0].(*widget.Label) if !ok { return } body, ok := content.Objects[1].(*widget.Label) if !ok { return } background.FillColor = chatBubbleColor(item) title.SetText(chatBubbleTitle(item)) body.SetText(chatBubbleBody(item)) background.Refresh() bubble.Refresh() } func isEncryptedChatPayload(payload string) bool { var envelope hybridEnvelope if err := json.Unmarshal([]byte(payload), &envelope); err != nil { return false } return strings.TrimSpace(envelope.EK) != "" && strings.TrimSpace(envelope.N) != "" && strings.TrimSpace(envelope.CT) != "" } func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { status := widget.NewLabel("Vyberte oblast obrazovky, nebo spusťte živé sledování chatu.") status.Wrapping = fyne.TextWrapWord count := widget.NewLabel("0 zpráv") var results []chatScanResult storedMessages, historyErr := svc.ListChatMessages() if historyErr != nil { status.SetText("Historii chatu se nepodařilo načíst: " + historyErr.Error()) } else { results = make([]chatScanResult, 0, len(storedMessages)) for _, message := range storedMessages { results = append(results, chatScanResult{ direction: message.Direction, plaintext: message.Text, createdAt: message.CreatedAt, }) } } sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) }) var list *widget.List var chatListHost *fyne.Container newChatList := func() *widget.List { chatList := widget.NewList( func() int { return len(results) }, func() fyne.CanvasObject { return newChatBubbleRow() }, func(i widget.ListItemID, obj fyne.CanvasObject) { if int(i) < 0 || int(i) >= len(results) { return } updateChatBubbleRow(obj, results[i]) }, ) chatList.HideSeparators = true return chatList } list = newChatList() refreshChatList := func() { if len(results) == 0 { list = newChatList() if chatListHost != nil { chatListHost.Objects = []fyne.CanvasObject{list} chatListHost.Refresh() } count.SetText("0 zpráv") return } for i, item := range results { list.SetItemHeight(i, chatBubbleHeight(item)) } list.Refresh() count.SetText(fmt.Sprintf("%d zpráv", len(results))) } decryptPayloads := func(payloads []string) []chatScanResult { decoded := make([]chatScanResult, 0, len(payloads)) for _, payload := range payloads { if !isEncryptedChatPayload(payload) { continue } message, err := svc.DecryptChat(payload) if err != nil { continue } decoded = append(decoded, chatScanResult{ direction: message.Direction, payload: payload, plaintext: message.Text, createdAt: message.CreatedAt, }) } return decoded } persistScannedMessages := func(decoded []chatScanResult) []chatScanResult { accepted := make([]chatScanResult, 0, len(decoded)) for _, item := range decoded { message, added, err := svc.AppendScannedChatMessage(item.direction, item.plaintext, item.payload, item.createdAt) if err != nil { continue } if !added { continue } item.createdAt = message.CreatedAt accepted = append(accepted, item) } return accepted } applyResults := func(payloads []string) { go func() { decoded := decryptPayloads(payloads) accepted := persistScannedMessages(decoded) fyne.Do(func() { results = append(results, accepted...) sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) }) refreshChatList() status.SetText(fmt.Sprintf("Načteno %d QR kódů, úspěšně dešifrováno %d zpráv, přidáno %d nových zpráv.", len(payloads), len(decoded), len(accepted))) }) }() } decodeImage := func(img image.Image) { if img == nil { parts.showToast("Žádný obrázek") return } status.SetText("Rozpoznávám QR kódy…") go func() { payloads, err := DecodeQRCodes(img) if err != nil { if single, singleErr := DecodeQR(img); singleErr == nil { payloads = []string{single} } else { fyne.Do(func() { status.SetText("QR kód nebyl nalezen: " + err.Error()) }) return } } applyResults(payloads) }() } scanBtn := widget.NewButtonWithIcon("Vybrat oblast obrazovky", theme.SearchIcon(), func() { status.SetText("Čekám na výběr oblasti…") go func() { img, err := captureScreenRegion() if err != nil { fyne.Do(func() { status.SetText("Sken obrazovky se nepodařil: " + err.Error()) }) return } decodeImage(img) }() }) var stopLive func() monitorID := 0 startLiveBtn := widget.NewButtonWithIcon("Spustit živé sledování", theme.MediaPlayIcon(), func() { if stopLive != nil { return } monitorID++ currentMonitorID := monitorID stopRequested := make(chan struct{}) stopLive = func() { select { case <-stopRequested: default: close(stopRequested) } monitorID++ stopLive = nil status.SetText("Živé sledování zastaveno.") } if strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome") { status.SetText("V systémovém dialogu vyberte okno nebo monitor s chatem.") } else { status.SetText("Vybírám oblast pro živé sledování…") } go func() { session, err := openScreenCaptureSession() if err != nil { fyne.Do(func() { status.SetText("Živé sledování se nepodařilo spustit: " + err.Error()) if monitorID == currentMonitorID { stopLive = nil } }) return } defer session.Close() ticker := time.NewTicker(time.Second) defer ticker.Stop() fyne.Do(func() { status.SetText("Živé sledování běží. QR kódy se kontrolují jednou za sekundu.") }) for { select { case <-stopRequested: return case <-ticker.C: img, captureErr := session.Capture() if captureErr != nil { fyne.Do(func() { status.SetText("Snímání selhalo: " + captureErr.Error()) }) continue } payloads, decodeErr := DecodeQRCodesFast(img) if decodeErr != nil { continue } decoded := persistScannedMessages(decryptPayloads(payloads)) fyne.Do(func() { for _, item := range decoded { results = append(results, item) } sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) }) refreshChatList() }) } } }() }) stopLiveBtn := widget.NewButtonWithIcon("Zastavit", theme.MediaStopIcon(), func() { if stopLive != nil { stopLive() } }) pasteBtn := widget.NewButtonWithIcon("Ze schránky", theme.ContentPasteIcon(), func() { img, err := readImageClipboard() if err != nil { parts.showToast("Chyba schránky: " + err.Error()) return } decodeImage(img) }) openBtn := widget.NewButtonWithIcon("Otevřít obrázek", theme.FolderOpenIcon(), func() { win := fyne.CurrentApp().Driver().AllWindows()[0] fd := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) { if err != nil || rc == nil { return } defer rc.Close() data, readErr := io.ReadAll(rc) if readErr != nil { parts.showToast("Chyba čtení souboru") return } img, _, decodeErr := image.Decode(bytes.NewReader(data)) if decodeErr != nil { parts.showToast("Neplatný obrázek: " + decodeErr.Error()) return } decodeImage(img) }, win) fd.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"})) fd.Show() }) clearBtn := widget.NewButtonWithIcon("Skrýt historii", theme.ContentClearIcon(), func() { if stopLive != nil { stopLive() } results = nil refreshChatList() status.SetText("Historie zůstala bezpečně uložená v trezoru a načte se při dalším otevření aplikace.") }) scanToolbar := container.NewVBox( container.NewHBox(scanBtn, startLiveBtn, stopLiveBtn), container.NewHBox(pasteBtn, openBtn, layout.NewSpacer(), clearBtn), ) debugPayload := widget.NewMultiLineEntry() debugPayload.SetPlaceHolder("Sem vložte encrypted payload pro ruční debug dešifrování…") debugPayload.SetMinRowsVisible(3) debugStatus := widget.NewLabel("") debugBtn := widget.NewButtonWithIcon("Dešifrovat payload", theme.ConfirmIcon(), func() { payload := strings.TrimSpace(debugPayload.Text) if payload == "" { debugStatus.SetText("Vložte payload.") return } debugStatus.SetText("Dešifruji…") go func() { if !isEncryptedChatPayload(payload) { fyne.Do(func() { debugStatus.SetText("Tento QR payload není šifrovaná chatová zpráva.") }) return } plaintext, err := svc.Decrypt(payload) fyne.Do(func() { if err != nil { debugStatus.SetText("Nelze dešifrovat: " + err.Error()) } else { debugStatus.SetText("Dešifrovaná zpráva (nebyla přidána do historie): " + plaintext) } }) }() }) debugPanel := container.NewVBox( widget.NewLabelWithStyle("Vývojový debug režim", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabel("Ručně vložte payload, pokud nelze zprávu načíst ze screenshotu."), debugPayload, debugBtn, debugStatus, ) debugPanel.Hide() debugToggle := widget.NewCheck("Zapnout vývojový debug režim", func(enabled bool) { if enabled { debugPanel.Show() } else { debugPanel.Hide() } debugPanel.Refresh() }) messageEntry := widget.NewMultiLineEntry() messageEntry.SetMinRowsVisible(3) messageEntry.SetPlaceHolder("Napište zprávu, kterou chcete poslat…") outgoingQR := canvas.NewImageFromImage(nil) outgoingQR.FillMode = canvas.ImageFillContain outgoingQR.SetMinSize(fyne.NewSize(200, 200)) outgoingStatus := widget.NewLabel("QR se zobrazí po zašifrování zprávy.") outgoingStatus.Wrapping = fyne.TextWrapWord var outgoingPayload string var outgoingVersion atomic.Uint64 var outgoingTimer *time.Timer clearOutgoingResult := func() { outgoingPayload = "" outgoingQR.Image = nil outgoingQR.Refresh() } contactDisplayName := func(contact Contact) string { name := strings.TrimSpace(contact.Name) if name == "" { name = "(bez názvu)" } if cn := extractCN(contact.Cert); cn != "" && !strings.Contains(name, cn) { name = fmt.Sprintf("%s (%s)", name, cn) } return name } shortContactID := func(id string) string { id = strings.TrimSpace(id) if len(id) > 6 { return id[:6] } if id != "" { return id } return "bez-id" } var contactSelect *widget.Select contactByLabel := map[string]Contact{} contactLabels := []string{} loadContacts := func() { contacts, err := svc.ListContacts() if err != nil { contactByLabel = map[string]Contact{} contactLabels = nil outgoingStatus.SetText("Kontakty se nepodařilo načíst: " + err.Error()) return } baseLabels := make([]string, len(contacts)) labelCounts := make(map[string]int, len(contacts)) for i, contact := range contacts { label := contactDisplayName(contact) baseLabels[i] = label labelCounts[label]++ } contactByLabel = make(map[string]Contact, len(contacts)) contactLabels = make([]string, 0, len(contacts)) for i, contact := range contacts { label := baseLabels[i] if labelCounts[label] > 1 { label = fmt.Sprintf("%s [%d:%s]", label, i+1, shortContactID(contact.ID)) } contactLabels = append(contactLabels, label) contactByLabel[label] = contact } } selectedContactCert := func() (string, bool) { if len(contactLabels) == 0 { clearOutgoingResult() outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.") return "", false } if contactSelect == nil || strings.TrimSpace(contactSelect.Selected) == "" { clearOutgoingResult() outgoingStatus.SetText("Vyberte příjemce ze seznamu kontaktů.") return "", false } contact, ok := contactByLabel[contactSelect.Selected] if !ok { clearOutgoingResult() outgoingStatus.SetText("Vybraný kontakt už není dostupný. Obnovte seznam kontaktů.") return "", false } cert := strings.TrimSpace(contact.Cert) if cert == "" { clearOutgoingResult() outgoingStatus.SetText("Vybraný kontakt nemá uložený certifikát.") return "", false } if _, err := encrypt.ParsePeerPublicKey(cert); err != nil { clearOutgoingResult() outgoingStatus.SetText("Vybraný kontakt má neplatný certifikát: " + err.Error()) return "", false } return cert, true } var scheduleOutgoingEncryption func() validateSelectedContact := func() { clearOutgoingResult() if cert, ok := selectedContactCert(); ok { _ = cert outgoingStatus.SetText("Příjemce vybrán. QR se připraví po krátké pauze při psaní.") } } loadContacts() contactSelect = widget.NewSelect(contactLabels, func(string) { validateSelectedContact() if scheduleOutgoingEncryption != nil { scheduleOutgoingEncryption() } }) contactSelect.PlaceHolder = "Vyberte uložený kontakt" if len(contactLabels) == 0 { contactSelect.Disable() outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.") } else { contactSelect.SetSelected(contactLabels[0]) } refreshContactsBtn := widget.NewButtonWithIcon("Obnovit kontakty", theme.ViewRefreshIcon(), func() { previous := contactSelect.Selected loadContacts() contactSelect.SetOptions(contactLabels) if len(contactLabels) == 0 { contactSelect.ClearSelected() contactSelect.Disable() outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.") return } contactSelect.Enable() if _, ok := contactByLabel[previous]; ok { contactSelect.SetSelected(previous) return } contactSelect.SetSelected(contactLabels[0]) }) scheduleOutgoingEncryption = func() { version := outgoingVersion.Add(1) if outgoingTimer != nil { outgoingTimer.Stop() } message := strings.TrimSpace(messageEntry.Text) if message == "" { clearOutgoingResult() outgoingStatus.SetText("Zadejte zprávu.") return } peer, ok := selectedContactCert() if !ok { return } outgoingStatus.SetText("QR připravím po krátké pauze…") outgoingTimer = time.AfterFunc(450*time.Millisecond, func() { payload, err := svc.EncryptChat(message, peer) if err != nil { fyne.Do(func() { if version == outgoingVersion.Load() { outgoingStatus.SetText("Šifrování se nepodařilo: " + err.Error()) } }) return } data, err := GenerateQRPNG(payload, 640) if err != nil { fyne.Do(func() { if version == outgoingVersion.Load() { outgoingStatus.SetText("QR se nepodařilo vytvořit: " + err.Error()) } }) return } img, err := LoadPNG(data) if err != nil { fyne.Do(func() { if version == outgoingVersion.Load() { outgoingStatus.SetText("QR se nepodařilo načíst: " + err.Error()) } }) return } if version != outgoingVersion.Load() { return } fyne.Do(func() { if version != outgoingVersion.Load() { return } outgoingPayload = payload outgoingQR.Image = img outgoingQR.Refresh() outgoingStatus.SetText("Hotovo. Zkopírujte QR obrázek a vložte ho do chatu.") }) }) } messageEntry.OnChanged = func(string) { scheduleOutgoingEncryption() } copyOutgoingBtn := widget.NewButtonWithIcon("Kopírovat QR pro chat", theme.ContentCopyIcon(), func() { copyImageToClipboard(outgoingQR.Image, parts) }) copyPayloadBtn := widget.NewButtonWithIcon("Kopírovat payload", theme.ContentCopyIcon(), func() { if outgoingPayload != "" { copyClip(outgoingPayload, parts) } }) incomingControls := container.NewVBox( widget.NewLabelWithStyle("Historie chatu", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), status, scanToolbar, debugToggle, debugPanel, container.NewHBox(widget.NewLabelWithStyle("Zprávy", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), count), ) chatListHost = container.NewStack(list) incoming := container.NewBorder(incomingControls, nil, nil, nil, chatListHost) outgoingForm := container.NewVBox( widget.NewLabelWithStyle("Odchozí zpráva", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabel("Příjemce"), container.NewBorder(nil, nil, nil, refreshContactsBtn, contactSelect), widget.NewLabel("Zpráva"), messageEntry, outgoingStatus, ) outgoingPreview := container.NewVBox( widget.NewLabel("Výsledný QR kód"), container.NewGridWithColumns(2, copyOutgoingBtn, copyPayloadBtn), container.NewCenter(outgoingQR), ) outgoing := container.NewScroll(container.NewVBox(outgoingForm, widget.NewSeparator(), outgoingPreview)) refreshChatList() intro := widget.NewLabel("Načtěte QR zprávu přímo z obrazovky. Dešifrovaný text zůstává pouze v tomto trezoru.") intro.Wrapping = fyne.TextWrapWord header := container.NewVBox( widget.NewLabelWithStyle("Skenovat chat", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), intro, ) split := container.NewHSplit(incoming, outgoing) split.SetOffset(0.58) return container.NewBorder(header, nil, nil, nil, split) } func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { tabs := container.NewAppTabs( container.NewTabItem("Skenovat chat", buildChatScannerTab(parts, svc)), container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath)), container.NewTabItem("Kontakty", buildContactsTab(parts, svc)), container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc)), ) fyne.CurrentApp().Settings().SetTheme(simpleTheme{}) header := container.NewVBox( container.NewHBox( widget.NewLabelWithStyle("Fckeuspy", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), widget.NewLabel("Lokální šifrovaný chat"), ), widget.NewSeparator(), ) return container.NewBorder(header, parts.toastLabel, nil, nil, tabs) }