feat(live chat): nastrel live skenovani chatu pro zobrazeni desifrovanych zprav v gui

This commit is contained in:
Lukas Batelka 2026-07-19 01:21:58 +02:00
parent a842645c88
commit 679a1e0776
5 changed files with 1134 additions and 6 deletions

View File

@ -7,7 +7,7 @@ Nástroj v Go pro asymetrické šifrování zpráv cizím veřejným klíčem.
Má dvě rozhraní:
1. Web (HTMX) jednoduché formuláře (encrypt / decrypt)
2. Desktop (Fyne v2) 3 záložky: Identita, Šifrování, Dešifrování (dark UI)
2. Desktop (Fyne v2) 4 záložky: Skenovat chat, Identita, Kontakty, Dešifrování (dark UI)
Vlastnosti:
@ -25,11 +25,32 @@ Vlastnosti:
* Go 1.21+
* Není potřeba databáze ani další služby
* Pro výběr oblasti obrazovky v GUI na Waylandu doporučeno `grim` + `slurp`
---
## Rychlý start (GUI)
Volitelné balíčky pro snímání části obrazovky přímo z desktop GUI:
```bash
sudo apt update
sudo apt install grim slurp
```
Ověření:
```bash
grim --version
slurp --version
```
Na Ubuntu GNOME může místo toho stačit i fallback:
```bash
sudo apt install gnome-screenshot
```
```bash
# 1) po buildnutí (go build .) stačí spustit binárku otevře se GUI
./fckeuspy-go
@ -151,9 +172,10 @@ go run . gui
Záložky:
1. Identita kopírování `public.pem` / `identity.crt`
2. Šifrování peer key, zpráva, tlačítka: Paste, Clear, Encrypt, Copy
3. Dešifrování payload: Paste+Decrypt, Clear (čistí i výsledek), Copy
1. Skenovat chat výběr oblasti obrazovky / schránka / soubor, čtení QR a dešifrování nalezených payloadů
2. Identita kopírování `public.pem` / `identity.crt`
3. Kontakty správa kontaktů a jejich certifikátů
4. Dešifrování ruční vložení payloadu: Paste+Decrypt, Clear (čistí i výsledek), Copy
GUI je trvale v dark stylu. Pamatuje poslední tab a velikost okna.

View File

@ -467,6 +467,43 @@ func DecodeQR(img image.Image) (string, error) {
return "", errors.New("no qr code found")
}
// DecodeQRCodes returns all QR payloads recognized in an image. It is used by
// the desktop chat scanner, where a selected screen region may contain more
// than one message.
func DecodeQRCodes(img image.Image) ([]string, error) {
if img == nil {
return nil, errors.New("prázdný obrázek")
}
codes, err := goqr.Recognize(img)
if err != nil || len(codes) == 0 {
if err == nil {
err = errors.New("QR kód nenalezen")
}
return nil, err
}
seen := make(map[string]struct{}, len(codes))
texts := make([]string, 0, len(codes))
for _, code := range codes {
if code == nil {
continue
}
text := string(code.Payload)
if text == "" {
continue
}
if _, ok := seen[text]; ok {
continue
}
seen[text] = struct{}{}
texts = append(texts, text)
}
if len(texts) == 0 {
return nil, errors.New("QR kód nenalezen")
}
return texts, nil
}
// LoadPNG decodes raw PNG bytes to image.Image.
func LoadPNG(b []byte) (image.Image, error) {
im, err := png.Decode(bytes.NewReader(b))

319
screen_capture.go Normal file
View File

@ -0,0 +1,319 @@
package main
import (
"bytes"
"errors"
"fmt"
"image"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
type screenCaptureSession struct {
capture func() (image.Image, error)
close func()
}
func (s *screenCaptureSession) Capture() (image.Image, error) {
return s.capture()
}
func (s *screenCaptureSession) Close() {
if s.close != nil {
s.close()
}
}
// captureScreenRegion opens the native desktop region selector and returns the
// selected pixels. The command is deliberately kept outside the web build and
// uses tools commonly available on Ubuntu desktops.
func captureScreenRegion() (image.Image, error) {
var attempts []error
if !strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome") {
if _, err := exec.LookPath("grim"); err == nil {
if _, err := exec.LookPath("slurp"); err == nil {
img, captureErr := captureWaylandRegion()
if captureErr == nil {
return img, nil
}
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", captureErr))
}
}
}
commands := []struct {
name string
args func(string) []string
}{
{name: "gnome-screenshot", args: func(path string) []string { return []string{"-a", "-f", path} }},
{name: "scrot", args: func(path string) []string { return []string{"-s", path} }},
{name: "import", args: func(path string) []string { return []string{path} }},
}
for _, candidate := range commands {
if _, err := exec.LookPath(candidate.name); err != nil {
continue
}
img, err := captureToFile(candidate.name, candidate.args)
if err == nil {
return img, nil
}
attempts = append(attempts, fmt.Errorf("%s: %w", candidate.name, err))
}
if _, err := exec.LookPath("flameshot"); err == nil {
img, err := captureToFile("flameshot", func(path string) []string {
return []string{"gui", "--path", path}
})
if err != nil {
attempts = append(attempts, fmt.Errorf("flameshot: %w", err))
} else {
return img, nil
}
}
if len(attempts) > 0 {
return nil, fmt.Errorf("snímání oblasti selhalo: %w", errors.Join(attempts...))
}
return nil, errors.New("nenalezen nástroj pro výběr oblasti (grim+slurp, gnome-screenshot, scrot, import nebo flameshot)")
}
// openScreenCaptureSession selects a persistent backend once and returns a
// capture function that can be called repeatedly without reopening the picker.
func openScreenCaptureSession() (*screenCaptureSession, error) {
var attempts []error
isGNOME := strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome")
if _, err := exec.LookPath("flameshot"); err == nil {
session, sessionErr := openFlameshotSession()
if sessionErr == nil {
return session, nil
}
attempts = append(attempts, fmt.Errorf("flameshot: %w", sessionErr))
}
if !isGNOME {
if _, err := exec.LookPath("grim"); err == nil {
if _, err := exec.LookPath("slurp"); err == nil {
selection, selectErr := selectWaylandRegion()
if selectErr == nil {
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureWaylandSelection(selection)
},
}
if _, captureErr := session.Capture(); captureErr == nil {
return session, nil
} else {
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", captureErr))
}
}
if selectErr != nil {
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", selectErr))
}
}
}
}
if _, err := exec.LookPath("slop"); err == nil {
if _, err := exec.LookPath("maim"); err == nil {
selection, selectErr := selectX11Region()
if selectErr == nil {
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureToFile("maim", func(path string) []string {
return []string{"-g", selection, path}
})
},
}
if _, captureErr := session.Capture(); captureErr == nil {
return session, nil
} else {
attempts = append(attempts, fmt.Errorf("slop/maim: %w", captureErr))
}
}
if selectErr != nil {
attempts = append(attempts, fmt.Errorf("slop/maim: %w", selectErr))
}
}
}
if len(attempts) > 0 {
return nil, fmt.Errorf("živé snímání oblasti selhalo: %w", errors.Join(attempts...))
}
if isGNOME {
return nil, errors.New("GNOME Wayland nepovoluje živé snímání libovolné oblasti přes gnome-screenshot; pro živé sledování nainstalujte Flameshot s podporou --last-region. Jednorázové snímání oblasti zůstává dostupné.")
}
return nil, errors.New("nenalezen nástroj pro živé snímání vybrané oblasti (Flameshot, grim+slurp nebo slop+maim)")
}
func openFlameshotSession() (*screenCaptureSession, error) {
selection, err := selectFlameshotRegion()
if err != nil {
return nil, fmt.Errorf("výběr oblasti: %w", err)
}
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureFlameshotSelection(selection)
},
}
if _, err := session.Capture(); err != nil {
return nil, fmt.Errorf("opakované snímání oblasti: %w", err)
}
return session, nil
}
func selectFlameshotRegion() (string, error) {
var stderr bytes.Buffer
command := exec.Command("flameshot", "gui", "--print-geometry", "--accept-on-select")
command.Stderr = &stderr
data, err := command.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("%w (%s)", err, detail)
}
return "", err
}
return parseFlameshotRegion(string(data))
}
func captureFlameshotSelection(selection string) (image.Image, error) {
return captureToFile("flameshot", func(path string) []string {
return []string{"screen", "--region", selection, "--path", path}
})
}
func parseFlameshotRegion(output string) (string, error) {
value := strings.TrimSpace(output)
var width, height, x, y int
if n, _ := fmt.Sscanf(value, "%dx%d+%d+%d", &width, &height, &x, &y); n != 4 {
values := strings.Fields(value)
if len(values) != 4 {
return "", errors.New("oblast nebyla vybrána")
}
var err error
if width, err = strconv.Atoi(values[0]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if height, err = strconv.Atoi(values[1]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if x, err = strconv.Atoi(values[2]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if y, err = strconv.Atoi(values[3]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
}
if width <= 0 || height <= 0 {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
return fmt.Sprintf("%dx%d+%d+%d", width, height, x, y), nil
}
func captureWaylandRegion() (image.Image, error) {
selection, err := selectWaylandRegion()
if err != nil {
return nil, err
}
return captureWaylandSelection(selection)
}
func selectWaylandRegion() (string, error) {
var stderr bytes.Buffer
slurp := exec.Command("slurp")
slurp.Stderr = &stderr
area, err := slurp.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("výběr oblasti: %w (%s)", err, detail)
}
return "", fmt.Errorf("výběr oblasti: %w", err)
}
selection := strings.TrimSpace(string(area))
if selection == "" {
return "", errors.New("oblast nebyla vybrána")
}
return selection, nil
}
func captureWaylandSelection(selection string) (image.Image, error) {
var stderr bytes.Buffer
tmp, err := os.CreateTemp("", "fckeuspy-screen-*.png")
if err != nil {
return nil, err
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return nil, err
}
defer os.Remove(path)
stderr.Reset()
grim := exec.Command("grim", "-g", selection, path)
grim.Stderr = &stderr
if err := grim.Run(); err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return nil, fmt.Errorf("snímek oblasti: %w (%s)", err, detail)
}
return nil, fmt.Errorf("snímek oblasti: %w", err)
}
return decodeImageFile(path)
}
func selectX11Region() (string, error) {
var stderr bytes.Buffer
slop := exec.Command("slop", "-f", "%g")
slop.Stderr = &stderr
area, err := slop.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("výběr oblasti: %w (%s)", err, detail)
}
return "", fmt.Errorf("výběr oblasti: %w", err)
}
selection := strings.TrimSpace(string(area))
if selection == "" {
return "", errors.New("oblast nebyla vybrána")
}
return selection, nil
}
func captureToFile(name string, args func(string) []string) (image.Image, error) {
tmp, err := os.CreateTemp("", "fckeuspy-screen-*.png")
if err != nil {
return nil, err
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return nil, err
}
defer os.Remove(path)
if err := exec.Command(name, args(path)...).Run(); err != nil {
return nil, err
}
return decodeImageFile(path)
}
func decodeImageFile(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return img, nil
}

679
ui.go
View File

@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
encrypt "fckeuspy-go/lib"
"fmt"
@ -14,6 +15,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
"fyne.io/fyne/v2"
@ -73,10 +75,24 @@ func (p *uiParts) showToast(s string) {
// 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{24, 27, 31, 255}
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}
@ -97,6 +113,8 @@ type ServiceFacade interface {
ListContacts() ([]Contact, error)
SaveContact(c Contact) error
DeleteContact(id string) error
ListChatMessages() ([]ChatMessage, error)
AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error)
}
// Clipboard helpers
@ -787,12 +805,669 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
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{})
return container.NewBorder(nil, parts.toastLabel, nil, nil, tabs)
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)
}

View File

@ -14,6 +14,8 @@ import (
encrypt "fckeuspy-go/lib"
"fmt"
mrand "math/rand"
"sync"
"time"
)
// VaultService implementuje ServiceFacade nad SecureJSONStore.
@ -25,6 +27,7 @@ type VaultService struct {
priv *rsa.PrivateKey
pubPEM string
certPEM string
chatMu sync.Mutex
}
func NewVaultService(store encrypt.SecureJSONStore) (*VaultService, error) {
@ -88,6 +91,19 @@ type Contact struct {
const contactsKey = "contacts"
const (
chatHistoryKey = "chat_history"
persistedChatDirectionIn = "incoming"
persistedChatDirectionOut = "outgoing"
)
type ChatMessage struct {
Direction string `json:"direction"`
Text string `json:"text"`
CreatedAt time.Time `json:"createdAt"`
PayloadHash string `json:"payloadHash"`
}
func (v *VaultService) ListContacts() ([]Contact, error) {
var list []Contact
if !v.store.Has(contactsKey) {
@ -99,6 +115,65 @@ func (v *VaultService) ListContacts() ([]Contact, error) {
return list, nil
}
func (v *VaultService) ListChatMessages() ([]ChatMessage, error) {
v.chatMu.Lock()
defer v.chatMu.Unlock()
return v.listChatMessagesLocked()
}
func (v *VaultService) listChatMessagesLocked() ([]ChatMessage, error) {
if !v.store.Has(chatHistoryKey) {
return []ChatMessage{}, nil
}
var messages []ChatMessage
if err := v.store.Get(chatHistoryKey, &messages); err != nil {
return nil, err
}
return messages, nil
}
func (v *VaultService) AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error) {
if direction != persistedChatDirectionIn && direction != persistedChatDirectionOut {
return ChatMessage{}, false, fmt.Errorf("invalid chat message direction %q", direction)
}
if payload == "" {
return ChatMessage{}, false, errors.New("empty chat payload")
}
payloadSum := sha256.Sum256([]byte(payload))
payloadHash := fmt.Sprintf("%x", payloadSum[:])
v.chatMu.Lock()
defer v.chatMu.Unlock()
messages, err := v.listChatMessagesLocked()
if err != nil {
return ChatMessage{}, false, err
}
for _, message := range messages {
if message.PayloadHash == payloadHash {
return message, false, nil
}
}
message := ChatMessage{
Direction: direction,
Text: text,
CreatedAt: time.Now().UTC(),
PayloadHash: payloadHash,
}
messages = append(messages, message)
if err := v.store.Put(chatHistoryKey, messages); err != nil {
return ChatMessage{}, false, err
}
if err := v.store.Flush(); err != nil {
return ChatMessage{}, false, err
}
return message, true, nil
}
func (v *VaultService) SaveContact(c Contact) error {
list, _ := v.ListContacts()
// upsert by ID; if empty ID, assign a new unique random ID to avoid overwriting