fckeuspy-go/ui.go

1478 lines
42 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
"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)
Decrypt(json string) (string, 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)
}
// 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 {
if message.Direction != chatUIDirectionIncoming {
continue
}
results = append(results, chatScanResult{
direction: message.Direction,
plaintext: message.Text,
createdAt: message.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
}
plaintext, err := svc.Decrypt(payload)
if err != nil {
continue
}
decoded = append(decoded, chatScanResult{
direction: chatUIDirectionIncoming,
payload: payload,
plaintext: plaintext,
})
}
return decoded
}
persistIncoming := func(decoded []chatScanResult) []chatScanResult {
accepted := make([]chatScanResult, 0, len(decoded))
for _, item := range decoded {
message, added, err := svc.AppendChatMessage(chatUIDirectionIncoming, item.plaintext, item.payload)
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 := persistIncoming(decoded)
fyne.Do(func() {
results = append(results, accepted...)
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(200 * time.Millisecond)
defer ticker.Stop()
fyne.Do(func() { status.SetText("Živé sledování běží. QR kódy se kontrolují 5× 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 := DecodeQRCodes(img)
if decodeErr != nil {
continue
}
decoded := persistIncoming(decryptPayloads(payloads))
fyne.Do(func() {
for _, item := range decoded {
results = append(results, item)
}
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(220, 220))
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.Encrypt(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(
container.NewHBox(widget.NewLabel("Výsledný QR kód"), layout.NewSpacer(), copyOutgoingBtn, copyPayloadBtn),
container.NewCenter(outgoingQR),
)
outgoing := container.NewScroll(container.NewHSplit(outgoingForm, 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.NewVSplit(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)
}