1474 lines
42 KiB
Go
Executable File
1474 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"
|
||
"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.")
|
||
}
|
||
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)
|
||
}
|