Compare commits

..

3 Commits

7 changed files with 1629 additions and 18 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í: Má dvě rozhraní:
1. Web (HTMX) jednoduché formuláře (encrypt / decrypt) 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: Vlastnosti:
@ -25,11 +25,32 @@ Vlastnosti:
* Go 1.21+ * Go 1.21+
* Není potřeba databáze ani další služby * 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) ## 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 ```bash
# 1) po buildnutí (go build .) stačí spustit binárku otevře se GUI # 1) po buildnutí (go build .) stačí spustit binárku otevře se GUI
./fckeuspy-go ./fckeuspy-go
@ -151,9 +172,10 @@ go run . gui
Záložky: Záložky:
1. Identita kopírování `public.pem` / `identity.crt` 1. Skenovat chat výběr oblasti obrazovky / schránka / soubor, čtení QR a dešifrování nalezených payloadů
2. Šifrování peer key, zpráva, tlačítka: Paste, Clear, Encrypt, Copy 2. Identita kopírování `public.pem` / `identity.crt`
3. Dešifrování payload: Paste+Decrypt, Clear (čistí i výsledek), Copy 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. GUI je trvale v dark stylu. Pamatuje poslední tab a velikost okna.

2
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.0
require ( require (
fyne.io/fyne/v2 v2.6.3 fyne.io/fyne/v2 v2.6.3
github.com/godbus/dbus/v5 v5.1.0
github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea github.com/liyue201/goqr v0.0.0-20200803022322-df443203d4ea
github.com/makiuchi-d/gozxing v0.1.0 github.com/makiuchi-d/gozxing v0.1.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
@ -25,7 +26,6 @@ require (
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect github.com/hack-pad/safejs v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect

259
portal_capture.go Normal file
View File

@ -0,0 +1,259 @@
package main
import (
"errors"
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
"github.com/godbus/dbus/v5"
)
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalObjectPath = dbus.ObjectPath("/org/freedesktop/portal/desktop")
screenCastInterface = "org.freedesktop.portal.ScreenCast"
requestInterface = "org.freedesktop.portal.Request"
sessionInterface = "org.freedesktop.portal.Session"
)
type portalStream struct {
NodeID uint32
Properties map[string]dbus.Variant
}
func openPortalScreenCastSession() (*screenCaptureSession, error) {
if _, err := exec.LookPath("gst-launch-1.0"); err != nil {
return nil, errors.New("chybí gst-launch-1.0 pro přenos GNOME ScreenCast")
}
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, fmt.Errorf("připojení k desktop portálu: %w", err)
}
closeConnection := true
defer func() {
if closeConnection {
conn.Close()
}
}()
portal := conn.Object(portalBusName, portalObjectPath)
token := fmt.Sprintf("fckeuspy%d", time.Now().UnixNano())
createResult, err := portalRequest(conn, portal, screenCastInterface+".CreateSession", map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(token + "create"),
"session_handle_token": dbus.MakeVariant(token + "session"),
})
if err != nil {
return nil, fmt.Errorf("vytvoření ScreenCast relace: %w", err)
}
sessionPath, err := portalObjectPathResult(createResult, "session_handle")
if err != nil {
return nil, err
}
sessionOpen := true
defer func() {
if sessionOpen {
_ = portal.Call(sessionInterface+".Close", 0, sessionPath).Err
}
}()
if _, err := portalRequest(conn, portal, screenCastInterface+".SelectSources", sessionPath, map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(token + "select"),
"types": dbus.MakeVariant(uint32(3)), // monitor or window
"multiple": dbus.MakeVariant(false),
"cursor_mode": dbus.MakeVariant(uint32(1)), // hidden
}); err != nil {
return nil, fmt.Errorf("výběr okna nebo monitoru: %w", err)
}
startResult, err := portalRequest(conn, portal, screenCastInterface+".Start", sessionPath, "", map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(token + "start"),
})
if err != nil {
return nil, fmt.Errorf("spuštění ScreenCast relace: %w", err)
}
streams, err := portalStreams(startResult)
if err != nil {
return nil, err
}
if len(streams) != 1 {
return nil, fmt.Errorf("ScreenCast vrátil %d streamů, očekáván je jeden", len(streams))
}
var remote dbus.UnixFD
if err := portal.Call(screenCastInterface+".OpenPipeWireRemote", 0, sessionPath, map[string]dbus.Variant{}).Store(&remote); err != nil {
return nil, fmt.Errorf("otevření PipeWire streamu: %w", err)
}
frames, err := openPipeWireFrameStream(remote, streams[0].NodeID)
if err != nil {
return nil, err
}
closeConnection = false
sessionOpen = false
return &screenCaptureSession{
capture: frames.Capture,
close: func() {
frames.Close()
_ = portal.Call(sessionInterface+".Close", 0, sessionPath).Err
conn.Close()
},
}, nil
}
func portalRequest(conn *dbus.Conn, portal dbus.BusObject, method string, args ...any) (map[string]dbus.Variant, error) {
signals := make(chan *dbus.Signal, 1)
conn.Signal(signals)
defer conn.RemoveSignal(signals)
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(requestInterface),
dbus.WithMatchMember("Response"),
); err != nil {
return nil, err
}
defer conn.RemoveMatchSignal(
dbus.WithMatchInterface(requestInterface),
dbus.WithMatchMember("Response"),
)
var requestPath dbus.ObjectPath
if err := portal.Call(method, 0, args...).Store(&requestPath); err != nil {
return nil, err
}
timeout := time.NewTimer(2 * time.Minute)
defer timeout.Stop()
select {
case <-timeout.C:
return nil, errors.New("desktop portál neodpověděl včas")
default:
}
for {
select {
case signal := <-signals:
if signal.Path != requestPath {
continue
}
if len(signal.Body) != 2 {
return nil, errors.New("neplatná odpověď desktop portálu")
}
response, ok := signal.Body[0].(uint32)
if !ok {
return nil, errors.New("neplatný stav odpovědi desktop portálu")
}
if response != 0 {
return nil, errors.New("výběr byl zrušen")
}
result, ok := signal.Body[1].(map[string]dbus.Variant)
if !ok {
return nil, errors.New("neplatná data odpovědi desktop portálu")
}
return result, nil
case <-timeout.C:
return nil, errors.New("desktop portál neodpověděl včas")
}
}
}
func portalObjectPathResult(result map[string]dbus.Variant, key string) (dbus.ObjectPath, error) {
value, ok := result[key]
if !ok {
return "", fmt.Errorf("desktop portál nevrátil %s", key)
}
var path dbus.ObjectPath
if err := value.Store(&path); err != nil {
return "", fmt.Errorf("neplatné %s: %w", key, err)
}
return path, nil
}
func portalStreams(result map[string]dbus.Variant) ([]portalStream, error) {
value, ok := result["streams"]
if !ok {
return nil, errors.New("desktop portál nevrátil žádný stream")
}
var streams []portalStream
if err := value.Store(&streams); err != nil {
return nil, fmt.Errorf("neplatná data streamu desktop portálu: %w", err)
}
return streams, nil
}
type pipeWireFrameStream struct {
dir string
command *exec.Cmd
}
func openPipeWireFrameStream(remote dbus.UnixFD, nodeID uint32) (*pipeWireFrameStream, error) {
dir, err := os.MkdirTemp("", "fckeuspy-pipewire-*")
if err != nil {
return nil, err
}
remoteFile := os.NewFile(uintptr(remote), "pipewire-remote")
command := exec.Command(
"gst-launch-1.0", "-q",
"pipewiresrc", "fd=3", fmt.Sprintf("path=%d", nodeID),
"!", "videoconvert",
"!", "videorate",
"!", "video/x-raw,framerate=5/1",
"!", "pngenc", "compression-level=1",
"!", "multifilesink", "location="+filepath.Join(dir, "frame-%05d.png"), "max-files=2",
)
command.ExtraFiles = []*os.File{remoteFile}
if err := command.Start(); err != nil {
remoteFile.Close()
os.Remove(dir)
return nil, fmt.Errorf("spuštění PipeWire čtečky: %w", err)
}
remoteFile.Close()
return &pipeWireFrameStream{dir: dir, command: command}, nil
}
func (s *pipeWireFrameStream) Capture() (image.Image, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, errors.New("PipeWire zatím neposkytl žádný snímek")
}
sort.Slice(entries, func(i, j int) bool {
left, leftErr := entries[i].Info()
right, rightErr := entries[j].Info()
if leftErr != nil || rightErr != nil {
return entries[i].Name() > entries[j].Name()
}
return left.ModTime().After(right.ModTime())
})
var lastErr error
for _, entry := range entries {
img, decodeErr := decodeImageFile(filepath.Join(s.dir, entry.Name()))
if decodeErr == nil {
return img, nil
}
lastErr = decodeErr
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("PipeWire zatím neposkytl žádný snímek")
}
func (s *pipeWireFrameStream) Close() {
if s.command.Process != nil {
_ = s.command.Process.Kill()
}
_, _ = s.command.Process.Wait()
entries, err := os.ReadDir(s.dir)
if err == nil {
for _, entry := range entries {
_ = os.Remove(filepath.Join(s.dir, entry.Name()))
}
}
_ = os.Remove(s.dir)
}

View File

@ -467,6 +467,165 @@ func DecodeQR(img image.Image) (string, error) {
return "", errors.New("no qr code found") 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")
}
seen := make(map[string]struct{})
texts := make([]string, 0)
addCodes := func(codes []*goqr.QRData) {
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)
}
}
recognize := func(candidate image.Image) bool {
codes, err := goqr.Recognize(candidate)
if err != nil {
return false
}
before := len(texts)
addCodes(codes)
return len(texts) > before
}
scale := func(src image.Image, factor int) image.Image {
bounds := src.Bounds()
if factor <= 1 || bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, bounds.Dx()*factor, bounds.Dy()*factor))
for y := 0; y < dst.Bounds().Dy(); y++ {
for x := 0; x < dst.Bounds().Dx(); x++ {
dst.Set(x, y, src.At(bounds.Min.X+x/factor, bounds.Min.Y+y/factor))
}
}
return dst
}
crop := func(src image.Image, x, y, width, height int) image.Image {
bounds := src.Bounds()
r := image.Rect(bounds.Min.X+x, bounds.Min.Y+y, bounds.Min.X+x+width, bounds.Min.Y+y+height)
if r.Min.X < bounds.Min.X || r.Min.Y < bounds.Min.Y || r.Max.X > bounds.Max.X || r.Max.Y > bounds.Max.Y {
return nil
}
out := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(out, out.Bounds(), src, r.Min, draw.Src)
return out
}
bounds := img.Bounds()
candidates := []image.Image{img}
if max(bounds.Dx(), bounds.Dy()) <= 2400 {
candidates = append(candidates, scale(img, 2))
}
for _, candidate := range candidates {
recognize(candidate)
}
// A selected chat region often contains several small QR codes. Scan four
// heavily overlapping tiles as well as the complete image so that one code
// does not hide another from the detector's global pass.
if bounds.Dx() >= 80 && bounds.Dy() >= 80 {
tileWidth := bounds.Dx() * 2 / 3
tileHeight := bounds.Dy() * 2 / 3
for _, origin := range [][2]int{
{0, 0},
{bounds.Dx() - tileWidth, 0},
{0, bounds.Dy() - tileHeight},
{bounds.Dx() - tileWidth, bounds.Dy() - tileHeight},
} {
tile := crop(img, origin[0], origin[1], tileWidth, tileHeight)
if tile == nil {
continue
}
found := recognize(tile)
if !found && max(tile.Bounds().Dx(), tile.Bounds().Dy()) <= 1600 {
found = recognize(scale(tile, 2))
}
if !found {
if text, err := DecodeQR(tile); err == nil && text != "" {
if _, exists := seen[text]; !exists {
seen[text] = struct{}{}
texts = append(texts, text)
}
}
}
}
}
if len(texts) == 0 {
if text, err := DecodeQR(img); err == nil && text != "" {
texts = append(texts, text)
}
}
if len(texts) == 0 {
return nil, errors.New("QR kód nenalezen")
}
return texts, nil
}
// DecodeQRCodesFast keeps the live scanner responsive. It avoids the expensive
// tiled DecodeQR fallback and only retries a reasonably sized frame once after
// nearest-neighbour upscaling.
func DecodeQRCodesFast(img image.Image) ([]string, error) {
if img == nil {
return nil, errors.New("prázdný obrázek")
}
decode := func(candidate image.Image) []string {
codes, err := goqr.Recognize(candidate)
if err != nil {
return nil
}
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 _, exists := seen[text]; exists {
continue
}
seen[text] = struct{}{}
texts = append(texts, text)
}
return texts
}
texts := decode(img)
if len(texts) > 0 {
return texts, nil
}
bounds := img.Bounds()
if max(bounds.Dx(), bounds.Dy()) <= 1600 {
scaled := image.NewRGBA(image.Rect(0, 0, bounds.Dx()*2, bounds.Dy()*2))
for y := 0; y < scaled.Bounds().Dy(); y++ {
for x := 0; x < scaled.Bounds().Dx(); x++ {
scaled.Set(x, y, img.At(bounds.Min.X+x/2, bounds.Min.Y+y/2))
}
}
texts = decode(scaled)
}
if len(texts) == 0 {
return nil, errors.New("QR kód nenalezen")
}
return texts, nil
}
// LoadPNG decodes raw PNG bytes to image.Image. // LoadPNG decodes raw PNG bytes to image.Image.
func LoadPNG(b []byte) (image.Image, error) { func LoadPNG(b []byte) (image.Image, error) {
im, err := png.Decode(bytes.NewReader(b)) im, err := png.Decode(bytes.NewReader(b))

324
screen_capture.go Normal file
View File

@ -0,0 +1,324 @@
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 isGNOME {
session, sessionErr := openPortalScreenCastSession()
if sessionErr == nil {
return session, nil
}
attempts = append(attempts, fmt.Errorf("GNOME ScreenCast: %w", sessionErr))
} else 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 vyžaduje pro živé sledování desktopový ScreenCast portál a PipeWire. 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
}

689
ui.go
View File

@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
encrypt "fckeuspy-go/lib" encrypt "fckeuspy-go/lib"
"fmt" "fmt"
@ -13,7 +14,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync/atomic"
"time" "time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@ -73,10 +76,24 @@ func (p *uiParts) showToast(s string) {
// Theme // Theme
type simpleTheme struct{} 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 { func (simpleTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
switch n { switch n {
case theme.ColorNameBackground: 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: case theme.ColorNameDisabled:
// Make disabled text brighter for readability on dark background // Make disabled text brighter for readability on dark background
return color.NRGBA{230, 233, 238, 255} return color.NRGBA{230, 233, 238, 255}
@ -91,12 +108,17 @@ func (simpleTheme) Size(n fyne.ThemeSizeName) float32 { return theme.Defau
// Facade interface // Facade interface
type ServiceFacade interface { type ServiceFacade interface {
Encrypt(msg, peer string) (string, error) Encrypt(msg, peer string) (string, error)
EncryptChat(msg, peer string) (string, error)
Decrypt(json string) (string, error) Decrypt(json string) (string, error)
DecryptChat(json string) (DecryptedChatMessage, error)
PublicPEM() string PublicPEM() string
PublicCert() string PublicCert() string
ListContacts() ([]Contact, error) ListContacts() ([]Contact, error)
SaveContact(c Contact) error SaveContact(c Contact) error
DeleteContact(id string) error DeleteContact(id string) error
ListChatMessages() ([]ChatMessage, error)
AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error)
AppendScannedChatMessage(direction, text, payload string, createdAt time.Time) (ChatMessage, bool, error)
} }
// Clipboard helpers // Clipboard helpers
@ -787,12 +809,675 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
return container.NewBorder(header, nil, nil, nil, container.NewBorder(search, nil, nil, nil, list)) return container.NewBorder(header, nil, nil, nil, container.NewBorder(search, nil, nil, nil, list))
} }
type chatScanResult struct {
direction string
payload string
plaintext string
err string
createdAt time.Time
}
const (
chatUIDirectionIncoming = "incoming"
chatUIDirectionOutgoing = "outgoing"
)
func chatBubbleTitle(item chatScanResult) string {
if item.err != "" {
return "Zprávu se nepodařilo dešifrovat"
}
timestamp := ""
if !item.createdAt.IsZero() {
timestamp = " · " + item.createdAt.Local().Format("15:04")
}
if item.direction == chatUIDirectionOutgoing {
return "Odchozí zpráva" + timestamp
}
return "Příchozí zpráva" + timestamp
}
func chatBubbleBody(item chatScanResult) string {
if item.err != "" {
return item.err
}
return item.plaintext
}
func chatBubbleColor(item chatScanResult) color.Color {
if item.err != "" {
return chatFailedBubbleColor
}
if item.direction == chatUIDirectionOutgoing {
return chatOutgoingBubbleColor
}
return chatIncomingBubbleColor
}
func chatBubbleHeight(item chatScanResult) float32 {
lineCount := 1
lineLen := 0
addLine := func() {
wrapped := (lineLen + 71) / 72
lineCount += max(wrapped, 1)
lineLen = 0
}
for _, r := range chatBubbleBody(item) {
if r == '\n' {
addLine()
continue
}
lineLen++
}
addLine()
return float32(42 + lineCount*22)
}
func newChatBubbleRow() fyne.CanvasObject {
background := canvas.NewRectangle(chatIncomingBubbleColor)
background.CornerRadius = 12
title := widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
title.Wrapping = fyne.TextWrapWord
body := widget.NewLabel("")
body.Wrapping = fyne.TextWrapWord
return container.NewStack(background, container.NewPadded(container.NewVBox(title, body)))
}
func updateChatBubbleRow(obj fyne.CanvasObject, item chatScanResult) {
bubble, ok := obj.(*fyne.Container)
if !ok || len(bubble.Objects) < 2 {
return
}
background, ok := bubble.Objects[0].(*canvas.Rectangle)
if !ok {
return
}
padded, ok := bubble.Objects[1].(*fyne.Container)
if !ok || len(padded.Objects) < 1 {
return
}
content, ok := padded.Objects[0].(*fyne.Container)
if !ok || len(content.Objects) < 2 {
return
}
title, ok := content.Objects[0].(*widget.Label)
if !ok {
return
}
body, ok := content.Objects[1].(*widget.Label)
if !ok {
return
}
background.FillColor = chatBubbleColor(item)
title.SetText(chatBubbleTitle(item))
body.SetText(chatBubbleBody(item))
background.Refresh()
bubble.Refresh()
}
func isEncryptedChatPayload(payload string) bool {
var envelope hybridEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return false
}
return strings.TrimSpace(envelope.EK) != "" &&
strings.TrimSpace(envelope.N) != "" &&
strings.TrimSpace(envelope.CT) != ""
}
func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
status := widget.NewLabel("Vyberte oblast obrazovky, nebo spusťte živé sledování chatu.")
status.Wrapping = fyne.TextWrapWord
count := widget.NewLabel("0 zpráv")
var results []chatScanResult
storedMessages, historyErr := svc.ListChatMessages()
if historyErr != nil {
status.SetText("Historii chatu se nepodařilo načíst: " + historyErr.Error())
} else {
results = make([]chatScanResult, 0, len(storedMessages))
for _, message := range storedMessages {
results = append(results, chatScanResult{
direction: message.Direction,
plaintext: message.Text,
createdAt: message.CreatedAt,
})
}
}
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
var list *widget.List
var chatListHost *fyne.Container
newChatList := func() *widget.List {
chatList := widget.NewList(
func() int { return len(results) },
func() fyne.CanvasObject {
return newChatBubbleRow()
},
func(i widget.ListItemID, obj fyne.CanvasObject) {
if int(i) < 0 || int(i) >= len(results) {
return
}
updateChatBubbleRow(obj, results[i])
},
)
chatList.HideSeparators = true
return chatList
}
list = newChatList()
refreshChatList := func() {
if len(results) == 0 {
list = newChatList()
if chatListHost != nil {
chatListHost.Objects = []fyne.CanvasObject{list}
chatListHost.Refresh()
}
count.SetText("0 zpráv")
return
}
for i, item := range results {
list.SetItemHeight(i, chatBubbleHeight(item))
}
list.Refresh()
count.SetText(fmt.Sprintf("%d zpráv", len(results)))
}
decryptPayloads := func(payloads []string) []chatScanResult {
decoded := make([]chatScanResult, 0, len(payloads))
for _, payload := range payloads {
if !isEncryptedChatPayload(payload) {
continue
}
message, err := svc.DecryptChat(payload)
if err != nil {
continue
}
decoded = append(decoded, chatScanResult{
direction: message.Direction,
payload: payload,
plaintext: message.Text,
createdAt: message.CreatedAt,
})
}
return decoded
}
persistScannedMessages := func(decoded []chatScanResult) []chatScanResult {
accepted := make([]chatScanResult, 0, len(decoded))
for _, item := range decoded {
message, added, err := svc.AppendScannedChatMessage(item.direction, item.plaintext, item.payload, item.createdAt)
if err != nil {
continue
}
if !added {
continue
}
item.createdAt = message.CreatedAt
accepted = append(accepted, item)
}
return accepted
}
applyResults := func(payloads []string) {
go func() {
decoded := decryptPayloads(payloads)
accepted := persistScannedMessages(decoded)
fyne.Do(func() {
results = append(results, accepted...)
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
refreshChatList()
status.SetText(fmt.Sprintf("Načteno %d QR kódů, úspěšně dešifrováno %d zpráv, přidáno %d nových zpráv.", len(payloads), len(decoded), len(accepted)))
})
}()
}
decodeImage := func(img image.Image) {
if img == nil {
parts.showToast("Žádný obrázek")
return
}
status.SetText("Rozpoznávám QR kódy…")
go func() {
payloads, err := DecodeQRCodes(img)
if err != nil {
if single, singleErr := DecodeQR(img); singleErr == nil {
payloads = []string{single}
} else {
fyne.Do(func() { status.SetText("QR kód nebyl nalezen: " + err.Error()) })
return
}
}
applyResults(payloads)
}()
}
scanBtn := widget.NewButtonWithIcon("Vybrat oblast obrazovky", theme.SearchIcon(), func() {
status.SetText("Čekám na výběr oblasti…")
go func() {
img, err := captureScreenRegion()
if err != nil {
fyne.Do(func() { status.SetText("Sken obrazovky se nepodařil: " + err.Error()) })
return
}
decodeImage(img)
}()
})
var stopLive func()
monitorID := 0
startLiveBtn := widget.NewButtonWithIcon("Spustit živé sledování", theme.MediaPlayIcon(), func() {
if stopLive != nil {
return
}
monitorID++
currentMonitorID := monitorID
stopRequested := make(chan struct{})
stopLive = func() {
select {
case <-stopRequested:
default:
close(stopRequested)
}
monitorID++
stopLive = nil
status.SetText("Živé sledování zastaveno.")
}
if strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome") {
status.SetText("V systémovém dialogu vyberte okno nebo monitor s chatem.")
} else {
status.SetText("Vybírám oblast pro živé sledování…")
}
go func() {
session, err := openScreenCaptureSession()
if err != nil {
fyne.Do(func() {
status.SetText("Živé sledování se nepodařilo spustit: " + err.Error())
if monitorID == currentMonitorID {
stopLive = nil
}
})
return
}
defer session.Close()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
fyne.Do(func() { status.SetText("Živé sledování běží. QR kódy se kontrolují jednou za sekundu.") })
for {
select {
case <-stopRequested:
return
case <-ticker.C:
img, captureErr := session.Capture()
if captureErr != nil {
fyne.Do(func() { status.SetText("Snímání selhalo: " + captureErr.Error()) })
continue
}
payloads, decodeErr := DecodeQRCodesFast(img)
if decodeErr != nil {
continue
}
decoded := persistScannedMessages(decryptPayloads(payloads))
fyne.Do(func() {
for _, item := range decoded {
results = append(results, item)
}
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
refreshChatList()
})
}
}
}()
})
stopLiveBtn := widget.NewButtonWithIcon("Zastavit", theme.MediaStopIcon(), func() {
if stopLive != nil {
stopLive()
}
})
pasteBtn := widget.NewButtonWithIcon("Ze schránky", theme.ContentPasteIcon(), func() {
img, err := readImageClipboard()
if err != nil {
parts.showToast("Chyba schránky: " + err.Error())
return
}
decodeImage(img)
})
openBtn := widget.NewButtonWithIcon("Otevřít obrázek", theme.FolderOpenIcon(), func() {
win := fyne.CurrentApp().Driver().AllWindows()[0]
fd := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) {
if err != nil || rc == nil {
return
}
defer rc.Close()
data, readErr := io.ReadAll(rc)
if readErr != nil {
parts.showToast("Chyba čtení souboru")
return
}
img, _, decodeErr := image.Decode(bytes.NewReader(data))
if decodeErr != nil {
parts.showToast("Neplatný obrázek: " + decodeErr.Error())
return
}
decodeImage(img)
}, win)
fd.SetFilter(storage.NewExtensionFileFilter([]string{".png", ".jpg", ".jpeg"}))
fd.Show()
})
clearBtn := widget.NewButtonWithIcon("Skrýt historii", theme.ContentClearIcon(), func() {
if stopLive != nil {
stopLive()
}
results = nil
refreshChatList()
status.SetText("Historie zůstala bezpečně uložená v trezoru a načte se při dalším otevření aplikace.")
})
scanToolbar := container.NewVBox(
container.NewHBox(scanBtn, startLiveBtn, stopLiveBtn),
container.NewHBox(pasteBtn, openBtn, layout.NewSpacer(), clearBtn),
)
debugPayload := widget.NewMultiLineEntry()
debugPayload.SetPlaceHolder("Sem vložte encrypted payload pro ruční debug dešifrování…")
debugPayload.SetMinRowsVisible(3)
debugStatus := widget.NewLabel("")
debugBtn := widget.NewButtonWithIcon("Dešifrovat payload", theme.ConfirmIcon(), func() {
payload := strings.TrimSpace(debugPayload.Text)
if payload == "" {
debugStatus.SetText("Vložte payload.")
return
}
debugStatus.SetText("Dešifruji…")
go func() {
if !isEncryptedChatPayload(payload) {
fyne.Do(func() { debugStatus.SetText("Tento QR payload není šifrovaná chatová zpráva.") })
return
}
plaintext, err := svc.Decrypt(payload)
fyne.Do(func() {
if err != nil {
debugStatus.SetText("Nelze dešifrovat: " + err.Error())
} else {
debugStatus.SetText("Dešifrovaná zpráva (nebyla přidána do historie): " + plaintext)
}
})
}()
})
debugPanel := container.NewVBox(
widget.NewLabelWithStyle("Vývojový debug režim", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("Ručně vložte payload, pokud nelze zprávu načíst ze screenshotu."),
debugPayload,
debugBtn,
debugStatus,
)
debugPanel.Hide()
debugToggle := widget.NewCheck("Zapnout vývojový debug režim", func(enabled bool) {
if enabled {
debugPanel.Show()
} else {
debugPanel.Hide()
}
debugPanel.Refresh()
})
messageEntry := widget.NewMultiLineEntry()
messageEntry.SetMinRowsVisible(3)
messageEntry.SetPlaceHolder("Napište zprávu, kterou chcete poslat…")
outgoingQR := canvas.NewImageFromImage(nil)
outgoingQR.FillMode = canvas.ImageFillContain
outgoingQR.SetMinSize(fyne.NewSize(200, 200))
outgoingStatus := widget.NewLabel("QR se zobrazí po zašifrování zprávy.")
outgoingStatus.Wrapping = fyne.TextWrapWord
var outgoingPayload string
var outgoingVersion atomic.Uint64
var outgoingTimer *time.Timer
clearOutgoingResult := func() {
outgoingPayload = ""
outgoingQR.Image = nil
outgoingQR.Refresh()
}
contactDisplayName := func(contact Contact) string {
name := strings.TrimSpace(contact.Name)
if name == "" {
name = "(bez názvu)"
}
if cn := extractCN(contact.Cert); cn != "" && !strings.Contains(name, cn) {
name = fmt.Sprintf("%s (%s)", name, cn)
}
return name
}
shortContactID := func(id string) string {
id = strings.TrimSpace(id)
if len(id) > 6 {
return id[:6]
}
if id != "" {
return id
}
return "bez-id"
}
var contactSelect *widget.Select
contactByLabel := map[string]Contact{}
contactLabels := []string{}
loadContacts := func() {
contacts, err := svc.ListContacts()
if err != nil {
contactByLabel = map[string]Contact{}
contactLabels = nil
outgoingStatus.SetText("Kontakty se nepodařilo načíst: " + err.Error())
return
}
baseLabels := make([]string, len(contacts))
labelCounts := make(map[string]int, len(contacts))
for i, contact := range contacts {
label := contactDisplayName(contact)
baseLabels[i] = label
labelCounts[label]++
}
contactByLabel = make(map[string]Contact, len(contacts))
contactLabels = make([]string, 0, len(contacts))
for i, contact := range contacts {
label := baseLabels[i]
if labelCounts[label] > 1 {
label = fmt.Sprintf("%s [%d:%s]", label, i+1, shortContactID(contact.ID))
}
contactLabels = append(contactLabels, label)
contactByLabel[label] = contact
}
}
selectedContactCert := func() (string, bool) {
if len(contactLabels) == 0 {
clearOutgoingResult()
outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.")
return "", false
}
if contactSelect == nil || strings.TrimSpace(contactSelect.Selected) == "" {
clearOutgoingResult()
outgoingStatus.SetText("Vyberte příjemce ze seznamu kontaktů.")
return "", false
}
contact, ok := contactByLabel[contactSelect.Selected]
if !ok {
clearOutgoingResult()
outgoingStatus.SetText("Vybraný kontakt už není dostupný. Obnovte seznam kontaktů.")
return "", false
}
cert := strings.TrimSpace(contact.Cert)
if cert == "" {
clearOutgoingResult()
outgoingStatus.SetText("Vybraný kontakt nemá uložený certifikát.")
return "", false
}
if _, err := encrypt.ParsePeerPublicKey(cert); err != nil {
clearOutgoingResult()
outgoingStatus.SetText("Vybraný kontakt má neplatný certifikát: " + err.Error())
return "", false
}
return cert, true
}
var scheduleOutgoingEncryption func()
validateSelectedContact := func() {
clearOutgoingResult()
if cert, ok := selectedContactCert(); ok {
_ = cert
outgoingStatus.SetText("Příjemce vybrán. QR se připraví po krátké pauze při psaní.")
}
}
loadContacts()
contactSelect = widget.NewSelect(contactLabels, func(string) {
validateSelectedContact()
if scheduleOutgoingEncryption != nil {
scheduleOutgoingEncryption()
}
})
contactSelect.PlaceHolder = "Vyberte uložený kontakt"
if len(contactLabels) == 0 {
contactSelect.Disable()
outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.")
} else {
contactSelect.SetSelected(contactLabels[0])
}
refreshContactsBtn := widget.NewButtonWithIcon("Obnovit kontakty", theme.ViewRefreshIcon(), func() {
previous := contactSelect.Selected
loadContacts()
contactSelect.SetOptions(contactLabels)
if len(contactLabels) == 0 {
contactSelect.ClearSelected()
contactSelect.Disable()
outgoingStatus.SetText("Nejdřív uložte kontakt s certifikátem v záložce Kontakty.")
return
}
contactSelect.Enable()
if _, ok := contactByLabel[previous]; ok {
contactSelect.SetSelected(previous)
return
}
contactSelect.SetSelected(contactLabels[0])
})
scheduleOutgoingEncryption = func() {
version := outgoingVersion.Add(1)
if outgoingTimer != nil {
outgoingTimer.Stop()
}
message := strings.TrimSpace(messageEntry.Text)
if message == "" {
clearOutgoingResult()
outgoingStatus.SetText("Zadejte zprávu.")
return
}
peer, ok := selectedContactCert()
if !ok {
return
}
outgoingStatus.SetText("QR připravím po krátké pauze…")
outgoingTimer = time.AfterFunc(450*time.Millisecond, func() {
payload, err := svc.EncryptChat(message, peer)
if err != nil {
fyne.Do(func() {
if version == outgoingVersion.Load() {
outgoingStatus.SetText("Šifrování se nepodařilo: " + err.Error())
}
})
return
}
data, err := GenerateQRPNG(payload, 640)
if err != nil {
fyne.Do(func() {
if version == outgoingVersion.Load() {
outgoingStatus.SetText("QR se nepodařilo vytvořit: " + err.Error())
}
})
return
}
img, err := LoadPNG(data)
if err != nil {
fyne.Do(func() {
if version == outgoingVersion.Load() {
outgoingStatus.SetText("QR se nepodařilo načíst: " + err.Error())
}
})
return
}
if version != outgoingVersion.Load() {
return
}
fyne.Do(func() {
if version != outgoingVersion.Load() {
return
}
outgoingPayload = payload
outgoingQR.Image = img
outgoingQR.Refresh()
outgoingStatus.SetText("Hotovo. Zkopírujte QR obrázek a vložte ho do chatu.")
})
})
}
messageEntry.OnChanged = func(string) { scheduleOutgoingEncryption() }
copyOutgoingBtn := widget.NewButtonWithIcon("Kopírovat QR pro chat", theme.ContentCopyIcon(), func() {
copyImageToClipboard(outgoingQR.Image, parts)
})
copyPayloadBtn := widget.NewButtonWithIcon("Kopírovat payload", theme.ContentCopyIcon(), func() {
if outgoingPayload != "" {
copyClip(outgoingPayload, parts)
}
})
incomingControls := container.NewVBox(
widget.NewLabelWithStyle("Historie chatu", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
status,
scanToolbar,
debugToggle,
debugPanel,
container.NewHBox(widget.NewLabelWithStyle("Zprávy", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), count),
)
chatListHost = container.NewStack(list)
incoming := container.NewBorder(incomingControls, nil, nil, nil, chatListHost)
outgoingForm := container.NewVBox(
widget.NewLabelWithStyle("Odchozí zpráva", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("Příjemce"),
container.NewBorder(nil, nil, nil, refreshContactsBtn, contactSelect),
widget.NewLabel("Zpráva"),
messageEntry,
outgoingStatus,
)
outgoingPreview := container.NewVBox(
widget.NewLabel("Výsledný QR kód"),
container.NewGridWithColumns(2, copyOutgoingBtn, copyPayloadBtn),
container.NewCenter(outgoingQR),
)
outgoing := container.NewScroll(container.NewVBox(outgoingForm, widget.NewSeparator(), outgoingPreview))
refreshChatList()
intro := widget.NewLabel("Načtěte QR zprávu přímo z obrazovky. Dešifrovaný text zůstává pouze v tomto trezoru.")
intro.Wrapping = fyne.TextWrapWord
header := container.NewVBox(
widget.NewLabelWithStyle("Skenovat chat", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
intro,
)
split := container.NewHSplit(incoming, outgoing)
split.SetOffset(0.58)
return container.NewBorder(header, nil, nil, nil, split)
}
func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject {
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItem("Skenovat chat", buildChatScannerTab(parts, svc)),
container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath)), container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath)),
container.NewTabItem("Kontakty", buildContactsTab(parts, svc)), container.NewTabItem("Kontakty", buildContactsTab(parts, svc)),
container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc)), container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc)),
) )
fyne.CurrentApp().Settings().SetTheme(simpleTheme{}) 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" encrypt "fckeuspy-go/lib"
"fmt" "fmt"
mrand "math/rand" mrand "math/rand"
"sync"
"time"
) )
// VaultService implementuje ServiceFacade nad SecureJSONStore. // VaultService implementuje ServiceFacade nad SecureJSONStore.
@ -25,6 +27,7 @@ type VaultService struct {
priv *rsa.PrivateKey priv *rsa.PrivateKey
pubPEM string pubPEM string
certPEM string certPEM string
chatMu sync.Mutex
} }
func NewVaultService(store encrypt.SecureJSONStore) (*VaultService, error) { func NewVaultService(store encrypt.SecureJSONStore) (*VaultService, error) {
@ -67,15 +70,25 @@ func (v *VaultService) Encrypt(message, peerPEMorCert string) (string, error) {
return encryptHybrid(v.priv, message, peerPEMorCert) return encryptHybrid(v.priv, message, peerPEMorCert)
} }
func (v *VaultService) EncryptChat(message, peerPEMorCert string) (string, error) {
return encryptChatHybrid(v.priv, message, peerPEMorCert)
}
// Decrypt provede rozšifrování. // Decrypt provede rozšifrování.
func (v *VaultService) Decrypt(payload string) (string, error) { return decryptHybrid(v.priv, payload) } func (v *VaultService) Decrypt(payload string) (string, error) { return decryptHybrid(v.priv, payload) }
func (v *VaultService) DecryptChat(payload string) (DecryptedChatMessage, error) {
return decryptChatHybrid(v.priv, payload)
}
// --- Lokální helpery (duplikace z encrypt.Service, zredukované) --- // --- Lokální helpery (duplikace z encrypt.Service, zredukované) ---
type hybridEnvelope struct { type hybridEnvelope struct {
EK string `json:"ek"` EK string `json:"ek"`
N string `json:"n"` N string `json:"n"`
CT string `json:"ct"` CT string `json:"ct"`
SelfEK string `json:"sek,omitempty"`
SentAt string `json:"at,omitempty"`
} }
// --- Contacts management --- // --- Contacts management ---
@ -88,6 +101,25 @@ type Contact struct {
const contactsKey = "contacts" 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"`
}
type DecryptedChatMessage struct {
Direction string
Text string
CreatedAt time.Time
}
func (v *VaultService) ListContacts() ([]Contact, error) { func (v *VaultService) ListContacts() ([]Contact, error) {
var list []Contact var list []Contact
if !v.store.Has(contactsKey) { if !v.store.Has(contactsKey) {
@ -99,6 +131,76 @@ func (v *VaultService) ListContacts() ([]Contact, error) {
return list, nil 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) {
return v.appendChatMessage(direction, text, payload, time.Now().UTC())
}
func (v *VaultService) AppendScannedChatMessage(direction, text, payload string, createdAt time.Time) (ChatMessage, bool, error) {
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return v.appendChatMessage(direction, text, payload, createdAt)
}
func (v *VaultService) appendChatMessage(direction, text, payload string, createdAt time.Time) (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: createdAt.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 { func (v *VaultService) SaveContact(c Contact) error {
list, _ := v.ListContacts() list, _ := v.ListContacts()
// upsert by ID; if empty ID, assign a new unique random ID to avoid overwriting // upsert by ID; if empty ID, assign a new unique random ID to avoid overwriting
@ -185,6 +287,17 @@ func extractCN(pemText string) string {
} }
func encryptHybrid(priv *rsa.PrivateKey, message, peerPEMorCert string) (string, error) { func encryptHybrid(priv *rsa.PrivateKey, message, peerPEMorCert string) (string, error) {
return encryptHybridEnvelope(nil, message, peerPEMorCert)
}
func encryptChatHybrid(priv *rsa.PrivateKey, message, peerPEMorCert string) (string, error) {
if priv == nil {
return "", errors.New("missing private key")
}
return encryptHybridEnvelope(&priv.PublicKey, message, peerPEMorCert)
}
func encryptHybridEnvelope(selfPublicKey *rsa.PublicKey, message, peerPEMorCert string) (string, error) {
pubKey, err := encrypt.ParsePeerPublicKey(peerPEMorCert) pubKey, err := encrypt.ParsePeerPublicKey(peerPEMorCert)
if err != nil { if err != nil {
return "", err return "", err
@ -210,27 +323,76 @@ func encryptHybrid(priv *rsa.PrivateKey, message, peerPEMorCert string) (string,
if err != nil { if err != nil {
return "", err return "", err
} }
env := hybridEnvelope{EK: base64.StdEncoding.EncodeToString(ek), N: base64.StdEncoding.EncodeToString(nonce), CT: base64.StdEncoding.EncodeToString(ct)} env := hybridEnvelope{
EK: base64.StdEncoding.EncodeToString(ek),
N: base64.StdEncoding.EncodeToString(nonce),
CT: base64.StdEncoding.EncodeToString(ct),
}
if selfPublicKey != nil {
selfEK, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, selfPublicKey, aesKey, []byte{})
if err != nil {
return "", err
}
env.SelfEK = base64.StdEncoding.EncodeToString(selfEK)
env.SentAt = time.Now().UTC().Format(time.RFC3339Nano)
}
out, _ := json.MarshalIndent(env, "", " ") out, _ := json.MarshalIndent(env, "", " ")
return string(out), nil return string(out), nil
} }
func decryptHybrid(priv *rsa.PrivateKey, payload string) (string, error) { func decryptHybrid(priv *rsa.PrivateKey, payload string) (string, error) {
env, nonce, ct, err := decodeHybridEnvelope(payload)
if err != nil {
return "", err
}
return decryptHybridPayload(priv, env.EK, nonce, ct)
}
func decryptChatHybrid(priv *rsa.PrivateKey, payload string) (DecryptedChatMessage, error) {
env, nonce, ct, err := decodeHybridEnvelope(payload)
if err != nil {
return DecryptedChatMessage{}, err
}
createdAt := time.Time{}
if env.SentAt != "" {
createdAt, _ = time.Parse(time.RFC3339Nano, env.SentAt)
}
if env.SelfEK != "" {
plaintext, selfErr := decryptHybridPayload(priv, env.SelfEK, nonce, ct)
if selfErr == nil {
return DecryptedChatMessage{Direction: persistedChatDirectionOut, Text: plaintext, CreatedAt: createdAt}, nil
}
}
plaintext, err := decryptHybridPayload(priv, env.EK, nonce, ct)
if err != nil {
return DecryptedChatMessage{}, err
}
return DecryptedChatMessage{Direction: persistedChatDirectionIn, Text: plaintext, CreatedAt: createdAt}, nil
}
func decodeHybridEnvelope(payload string) (hybridEnvelope, []byte, []byte, error) {
var env hybridEnvelope var env hybridEnvelope
if err := json.Unmarshal([]byte(payload), &env); err != nil { if err := json.Unmarshal([]byte(payload), &env); err != nil {
return "", fmt.Errorf("invalid JSON: %w", err) return hybridEnvelope{}, nil, nil, fmt.Errorf("invalid JSON: %w", err)
}
ek, err := base64.StdEncoding.DecodeString(env.EK)
if err != nil {
return "", fmt.Errorf("ek b64: %w", err)
} }
nonce, err := base64.StdEncoding.DecodeString(env.N) nonce, err := base64.StdEncoding.DecodeString(env.N)
if err != nil { if err != nil {
return "", fmt.Errorf("n b64: %w", err) return hybridEnvelope{}, nil, nil, fmt.Errorf("n b64: %w", err)
} }
ct, err := base64.StdEncoding.DecodeString(env.CT) ct, err := base64.StdEncoding.DecodeString(env.CT)
if err != nil { if err != nil {
return "", fmt.Errorf("ct b64: %w", err) return hybridEnvelope{}, nil, nil, fmt.Errorf("ct b64: %w", err)
}
return env, nonce, ct, nil
}
func decryptHybridPayload(priv *rsa.PrivateKey, encodedKey string, nonce, ct []byte) (string, error) {
if priv == nil {
return "", errors.New("missing private key")
}
ek, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
return "", fmt.Errorf("ek b64: %w", err)
} }
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ek, []byte{}) aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ek, []byte{})
if err != nil { if err != nil {