From 679a1e0776774e2b13c94d2c1bc09ae6601162d6 Mon Sep 17 00:00:00 2001 From: Lukas Batelka Date: Sun, 19 Jul 2026 01:21:58 +0200 Subject: [PATCH] feat(live chat): nastrel live skenovani chatu pro zobrazeni desifrovanych zprav v gui --- README.md | 30 +- qr_support.go | 37 +++ screen_capture.go | 319 ++++++++++++++++++++++ ui.go | 679 +++++++++++++++++++++++++++++++++++++++++++++- vault_service.go | 75 +++++ 5 files changed, 1134 insertions(+), 6 deletions(-) create mode 100644 screen_capture.go diff --git a/README.md b/README.md index 8a298e9..a6ef729 100755 --- a/README.md +++ b/README.md @@ -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. diff --git a/qr_support.go b/qr_support.go index 9877685..c161913 100755 --- a/qr_support.go +++ b/qr_support.go @@ -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)) diff --git a/screen_capture.go b/screen_capture.go new file mode 100644 index 0000000..b941c85 --- /dev/null +++ b/screen_capture.go @@ -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 +} diff --git a/ui.go b/ui.go index a940be7..495e9e3 100755 --- a/ui.go +++ b/ui.go @@ -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) } diff --git a/vault_service.go b/vault_service.go index d6c1316..2096b14 100755 --- a/vault_service.go +++ b/vault_service.go @@ -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