fckeuspy-go/ui.go

1484 lines
42 KiB
Go
Executable File

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)
}