Compare commits
3 Commits
main
...
feature/li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84fe2eee98 | ||
|
|
7e6c859856 | ||
|
|
679a1e0776 |
30
README.md
30
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í:
|
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
2
go.mod
@ -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
259
portal_capture.go
Normal 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)
|
||||||
|
}
|
||||||
159
qr_support.go
159
qr_support.go
@ -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
324
screen_capture.go
Normal 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
689
ui.go
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
184
vault_service.go
184
vault_service.go
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user