feat(live scan): scanuje z okna zpravy - ale je to pomale

This commit is contained in:
Lukas Batelka 2026-07-19 02:26:43 +02:00
parent 7e6c859856
commit 84fe2eee98
4 changed files with 274 additions and 48 deletions

View File

@ -230,7 +230,18 @@ func (s *pipeWireFrameStream) Capture() (image.Image, error) {
} }
return left.ModTime().After(right.ModTime()) return left.ModTime().After(right.ModTime())
}) })
return decodeImageFile(filepath.Join(s.dir, entries[0].Name())) 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() { func (s *pipeWireFrameStream) Close() {

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")
} }

42
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)))
}) })
@ -1095,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:
@ -1108,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()
}) })
} }
@ -1219,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
@ -1370,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() {
@ -1440,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.")
@ -1452,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 {