Compare commits

..

2 Commits

Author SHA1 Message Date
Lukas Batelka
84fe2eee98 feat(live scan): scanuje z okna zpravy - ale je to pomale 2026-07-19 02:26:43 +02:00
Lukas Batelka
7e6c859856 feat(live chat): funkcni live snimani chatu a zobrzovani desifrovanych zprav 2026-07-19 01:36:04 +02:00
6 changed files with 535 additions and 52 deletions

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

@ -474,16 +474,9 @@ func DecodeQRCodes(img image.Image) ([]string, error) {
if img == nil { if img == nil {
return nil, errors.New("prázdný obrázek") return nil, errors.New("prázdný obrázek")
} }
codes, err := goqr.Recognize(img) seen := make(map[string]struct{})
if err != nil || len(codes) == 0 { texts := make([]string, 0)
if err == nil { addCodes := func(codes []*goqr.QRData) {
err = errors.New("QR kód nenalezen")
}
return nil, err
}
seen := make(map[string]struct{}, len(codes))
texts := make([]string, 0, len(codes))
for _, code := range codes { for _, code := range codes {
if code == nil { if code == nil {
continue continue
@ -498,6 +491,135 @@ func DecodeQRCodes(img image.Image) ([]string, error) {
seen[text] = struct{}{} seen[text] = struct{}{}
texts = append(texts, text) 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 { if len(texts) == 0 {
return nil, errors.New("QR kód nenalezen") return nil, errors.New("QR kód nenalezen")
} }

View File

@ -86,8 +86,13 @@ func captureScreenRegion() (image.Image, error) {
func openScreenCaptureSession() (*screenCaptureSession, error) { func openScreenCaptureSession() (*screenCaptureSession, error) {
var attempts []error var attempts []error
isGNOME := strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome") isGNOME := strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome")
if isGNOME {
if _, err := exec.LookPath("flameshot"); err == nil { 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() session, sessionErr := openFlameshotSession()
if sessionErr == nil { if sessionErr == nil {
return session, nil return session, nil
@ -145,7 +150,7 @@ func openScreenCaptureSession() (*screenCaptureSession, error) {
return nil, fmt.Errorf("živé snímání oblasti selhalo: %w", errors.Join(attempts...)) return nil, fmt.Errorf("živé snímání oblasti selhalo: %w", errors.Join(attempts...))
} }
if isGNOME { if isGNOME {
return nil, errors.New("GNOME Wayland nepovoluje živé snímání libovolné oblasti přes gnome-screenshot; pro živé sledování nainstalujte Flameshot s podporou --last-region. Jednorázové snímání oblasti zůstává dostupné.") return nil, errors.New("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)") return nil, errors.New("nenalezen nástroj pro živé snímání vybrané oblasti (Flameshot, grim+slurp nebo slop+maim)")
} }

46
ui.go
View File

@ -14,6 +14,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -107,7 +108,9 @@ 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)
@ -115,6 +118,7 @@ type ServiceFacade interface {
DeleteContact(id string) error DeleteContact(id string) error
ListChatMessages() ([]ChatMessage, error) ListChatMessages() ([]ChatMessage, error)
AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error) AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error)
AppendScannedChatMessage(direction, text, payload string, createdAt time.Time) (ChatMessage, bool, error)
} }
// Clipboard helpers // Clipboard helpers
@ -932,9 +936,6 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
} else { } else {
results = make([]chatScanResult, 0, len(storedMessages)) results = make([]chatScanResult, 0, len(storedMessages))
for _, message := range storedMessages { for _, message := range storedMessages {
if message.Direction != chatUIDirectionIncoming {
continue
}
results = append(results, chatScanResult{ results = append(results, chatScanResult{
direction: message.Direction, direction: message.Direction,
plaintext: message.Text, plaintext: message.Text,
@ -942,6 +943,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
}) })
} }
} }
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
var list *widget.List var list *widget.List
var chatListHost *fyne.Container var chatListHost *fyne.Container
@ -987,23 +989,24 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
if !isEncryptedChatPayload(payload) { if !isEncryptedChatPayload(payload) {
continue continue
} }
plaintext, err := svc.Decrypt(payload) message, err := svc.DecryptChat(payload)
if err != nil { if err != nil {
continue continue
} }
decoded = append(decoded, chatScanResult{ decoded = append(decoded, chatScanResult{
direction: chatUIDirectionIncoming, direction: message.Direction,
payload: payload, payload: payload,
plaintext: plaintext, plaintext: message.Text,
createdAt: message.CreatedAt,
}) })
} }
return decoded return decoded
} }
persistIncoming := func(decoded []chatScanResult) []chatScanResult { persistScannedMessages := func(decoded []chatScanResult) []chatScanResult {
accepted := make([]chatScanResult, 0, len(decoded)) accepted := make([]chatScanResult, 0, len(decoded))
for _, item := range decoded { for _, item := range decoded {
message, added, err := svc.AppendChatMessage(chatUIDirectionIncoming, item.plaintext, item.payload) message, added, err := svc.AppendScannedChatMessage(item.direction, item.plaintext, item.payload, item.createdAt)
if err != nil { if err != nil {
continue continue
} }
@ -1019,9 +1022,10 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
applyResults := func(payloads []string) { applyResults := func(payloads []string) {
go func() { go func() {
decoded := decryptPayloads(payloads) decoded := decryptPayloads(payloads)
accepted := persistIncoming(decoded) accepted := persistScannedMessages(decoded)
fyne.Do(func() { fyne.Do(func() {
results = append(results, accepted...) results = append(results, accepted...)
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
refreshChatList() 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))) 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)))
}) })
@ -1078,7 +1082,11 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
stopLive = nil stopLive = nil
status.SetText("Živé sledování zastaveno.") 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í…") status.SetText("Vybírám oblast pro živé sledování…")
}
go func() { go func() {
session, err := openScreenCaptureSession() session, err := openScreenCaptureSession()
if err != nil { if err != nil {
@ -1091,9 +1099,9 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
return return
} }
defer session.Close() defer session.Close()
ticker := time.NewTicker(200 * time.Millisecond) ticker := time.NewTicker(time.Second)
defer ticker.Stop() defer ticker.Stop()
fyne.Do(func() { status.SetText("Živé sledování běží. QR kódy se kontrolují 5× za sekundu.") }) fyne.Do(func() { status.SetText("Živé sledování běží. QR kódy se kontrolují jednou za sekundu.") })
for { for {
select { select {
case <-stopRequested: case <-stopRequested:
@ -1104,15 +1112,16 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
fyne.Do(func() { status.SetText("Snímání selhalo: " + captureErr.Error()) }) fyne.Do(func() { status.SetText("Snímání selhalo: " + captureErr.Error()) })
continue continue
} }
payloads, decodeErr := DecodeQRCodes(img) payloads, decodeErr := DecodeQRCodesFast(img)
if decodeErr != nil { if decodeErr != nil {
continue continue
} }
decoded := persistIncoming(decryptPayloads(payloads)) decoded := persistScannedMessages(decryptPayloads(payloads))
fyne.Do(func() { fyne.Do(func() {
for _, item := range decoded { for _, item := range decoded {
results = append(results, item) results = append(results, item)
} }
sort.SliceStable(results, func(i, j int) bool { return results[i].createdAt.Before(results[j].createdAt) })
refreshChatList() refreshChatList()
}) })
} }
@ -1215,7 +1224,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
messageEntry.SetPlaceHolder("Napište zprávu, kterou chcete poslat…") messageEntry.SetPlaceHolder("Napište zprávu, kterou chcete poslat…")
outgoingQR := canvas.NewImageFromImage(nil) outgoingQR := canvas.NewImageFromImage(nil)
outgoingQR.FillMode = canvas.ImageFillContain outgoingQR.FillMode = canvas.ImageFillContain
outgoingQR.SetMinSize(fyne.NewSize(220, 220)) outgoingQR.SetMinSize(fyne.NewSize(200, 200))
outgoingStatus := widget.NewLabel("QR se zobrazí po zašifrování zprávy.") outgoingStatus := widget.NewLabel("QR se zobrazí po zašifrování zprávy.")
outgoingStatus.Wrapping = fyne.TextWrapWord outgoingStatus.Wrapping = fyne.TextWrapWord
var outgoingPayload string var outgoingPayload string
@ -1366,7 +1375,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
} }
outgoingStatus.SetText("QR připravím po krátké pauze…") outgoingStatus.SetText("QR připravím po krátké pauze…")
outgoingTimer = time.AfterFunc(450*time.Millisecond, func() { outgoingTimer = time.AfterFunc(450*time.Millisecond, func() {
payload, err := svc.Encrypt(message, peer) payload, err := svc.EncryptChat(message, peer)
if err != nil { if err != nil {
fyne.Do(func() { fyne.Do(func() {
if version == outgoingVersion.Load() { if version == outgoingVersion.Load() {
@ -1436,10 +1445,11 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
outgoingStatus, outgoingStatus,
) )
outgoingPreview := container.NewVBox( outgoingPreview := container.NewVBox(
container.NewHBox(widget.NewLabel("Výsledný QR kód"), layout.NewSpacer(), copyOutgoingBtn, copyPayloadBtn), widget.NewLabel("Výsledný QR kód"),
container.NewGridWithColumns(2, copyOutgoingBtn, copyPayloadBtn),
container.NewCenter(outgoingQR), container.NewCenter(outgoingQR),
) )
outgoing := container.NewScroll(container.NewHSplit(outgoingForm, outgoingPreview)) outgoing := container.NewScroll(container.NewVBox(outgoingForm, widget.NewSeparator(), outgoingPreview))
refreshChatList() 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 := widget.NewLabel("Načtěte QR zprávu přímo z obrazovky. Dešifrovaný text zůstává pouze v tomto trezoru.")
@ -1448,7 +1458,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
widget.NewLabelWithStyle("Skenovat chat", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Skenovat chat", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
intro, intro,
) )
split := container.NewVSplit(incoming, outgoing) split := container.NewHSplit(incoming, outgoing)
split.SetOffset(0.58) split.SetOffset(0.58)
return container.NewBorder(header, nil, nil, nil, split) return container.NewBorder(header, nil, nil, nil, split)
} }

View File

@ -70,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 ---
@ -104,6 +114,12 @@ type ChatMessage struct {
PayloadHash string `json:"payloadHash"` 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) {
@ -135,6 +151,17 @@ func (v *VaultService) listChatMessagesLocked() ([]ChatMessage, error) {
} }
func (v *VaultService) AppendChatMessage(direction, text, payload string) (ChatMessage, bool, error) { 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 { if direction != persistedChatDirectionIn && direction != persistedChatDirectionOut {
return ChatMessage{}, false, fmt.Errorf("invalid chat message direction %q", direction) return ChatMessage{}, false, fmt.Errorf("invalid chat message direction %q", direction)
} }
@ -161,7 +188,7 @@ func (v *VaultService) AppendChatMessage(direction, text, payload string) (ChatM
message := ChatMessage{ message := ChatMessage{
Direction: direction, Direction: direction,
Text: text, Text: text,
CreatedAt: time.Now().UTC(), CreatedAt: createdAt.UTC(),
PayloadHash: payloadHash, PayloadHash: payloadHash,
} }
messages = append(messages, message) messages = append(messages, message)
@ -260,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
@ -285,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 {