diff --git a/portal_capture.go b/portal_capture.go index 6c75b36..f1e968d 100644 --- a/portal_capture.go +++ b/portal_capture.go @@ -230,7 +230,18 @@ func (s *pipeWireFrameStream) Capture() (image.Image, error) { } 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() { diff --git a/qr_support.go b/qr_support.go index c161913..c0b9e0c 100755 --- a/qr_support.go +++ b/qr_support.go @@ -474,29 +474,151 @@ func DecodeQRCodes(img image.Image) ([]string, error) { if img == nil { return nil, errors.New("prázdný obrázek") } - codes, err := goqr.Recognize(img) - if err != nil || len(codes) == 0 { - if err == nil { - err = errors.New("QR kód nenalezen") + 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) } - return nil, err + } + 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 } - seen := make(map[string]struct{}, len(codes)) - texts := make([]string, 0, len(codes)) - for _, code := range codes { - if code == nil { - continue + 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) + } + } + } } - text := string(code.Payload) - if text == "" { - continue + } + if len(texts) == 0 { + if text, err := DecodeQR(img); err == nil && text != "" { + texts = append(texts, text) } - if _, ok := seen[text]; ok { - continue + } + 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[text] = struct{}{} - texts = append(texts, text) + 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") diff --git a/ui.go b/ui.go index 80e29dc..e33d6df 100755 --- a/ui.go +++ b/ui.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync/atomic" "time" @@ -107,7 +108,9 @@ func (simpleTheme) Size(n fyne.ThemeSizeName) float32 { return theme.Defau // Facade interface type ServiceFacade interface { Encrypt(msg, peer string) (string, error) + EncryptChat(msg, peer string) (string, error) Decrypt(json string) (string, error) + DecryptChat(json string) (DecryptedChatMessage, error) PublicPEM() string PublicCert() string ListContacts() ([]Contact, error) @@ -115,6 +118,7 @@ type ServiceFacade interface { 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 @@ -932,9 +936,6 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { } else { results = make([]chatScanResult, 0, len(storedMessages)) for _, message := range storedMessages { - if message.Direction != chatUIDirectionIncoming { - continue - } results = append(results, chatScanResult{ direction: message.Direction, plaintext: message.Text, @@ -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 chatListHost *fyne.Container @@ -987,23 +989,24 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { if !isEncryptedChatPayload(payload) { continue } - plaintext, err := svc.Decrypt(payload) + message, err := svc.DecryptChat(payload) if err != nil { continue } decoded = append(decoded, chatScanResult{ - direction: chatUIDirectionIncoming, + direction: message.Direction, payload: payload, - plaintext: plaintext, + plaintext: message.Text, + createdAt: message.CreatedAt, }) } return decoded } - persistIncoming := func(decoded []chatScanResult) []chatScanResult { + persistScannedMessages := func(decoded []chatScanResult) []chatScanResult { accepted := make([]chatScanResult, 0, len(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 { continue } @@ -1019,9 +1022,10 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { applyResults := func(payloads []string) { go func() { decoded := decryptPayloads(payloads) - accepted := persistIncoming(decoded) + 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))) }) @@ -1095,9 +1099,9 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { return } defer session.Close() - ticker := time.NewTicker(200 * time.Millisecond) + ticker := time.NewTicker(time.Second) 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 { select { 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()) }) continue } - payloads, decodeErr := DecodeQRCodes(img) + payloads, decodeErr := DecodeQRCodesFast(img) if decodeErr != nil { continue } - decoded := persistIncoming(decryptPayloads(payloads)) + 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() }) } @@ -1219,7 +1224,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { messageEntry.SetPlaceHolder("Napište zprávu, kterou chcete poslat…") outgoingQR := canvas.NewImageFromImage(nil) 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.Wrapping = fyne.TextWrapWord 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…") outgoingTimer = time.AfterFunc(450*time.Millisecond, func() { - payload, err := svc.Encrypt(message, peer) + payload, err := svc.EncryptChat(message, peer) if err != nil { fyne.Do(func() { if version == outgoingVersion.Load() { @@ -1440,10 +1445,11 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { outgoingStatus, ) 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), ) - outgoing := container.NewScroll(container.NewHSplit(outgoingForm, outgoingPreview)) + 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.") @@ -1452,7 +1458,7 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { widget.NewLabelWithStyle("Skenovat chat", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), intro, ) - split := container.NewVSplit(incoming, outgoing) + split := container.NewHSplit(incoming, outgoing) split.SetOffset(0.58) return container.NewBorder(header, nil, nil, nil, split) } diff --git a/vault_service.go b/vault_service.go index 2096b14..249c569 100755 --- a/vault_service.go +++ b/vault_service.go @@ -70,15 +70,25 @@ func (v *VaultService) Encrypt(message, peerPEMorCert string) (string, error) { 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í. 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é) --- type hybridEnvelope struct { - EK string `json:"ek"` - N string `json:"n"` - CT string `json:"ct"` + EK string `json:"ek"` + N string `json:"n"` + CT string `json:"ct"` + SelfEK string `json:"sek,omitempty"` + SentAt string `json:"at,omitempty"` } // --- Contacts management --- @@ -104,6 +114,12 @@ type ChatMessage struct { PayloadHash string `json:"payloadHash"` } +type DecryptedChatMessage struct { + Direction string + Text string + CreatedAt time.Time +} + func (v *VaultService) ListContacts() ([]Contact, error) { var list []Contact 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) { + 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) } @@ -161,7 +188,7 @@ func (v *VaultService) AppendChatMessage(direction, text, payload string) (ChatM message := ChatMessage{ Direction: direction, Text: text, - CreatedAt: time.Now().UTC(), + CreatedAt: createdAt.UTC(), PayloadHash: payloadHash, } messages = append(messages, message) @@ -260,6 +287,17 @@ func extractCN(pemText string) string { } 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) if err != nil { return "", err @@ -285,27 +323,76 @@ func encryptHybrid(priv *rsa.PrivateKey, message, peerPEMorCert string) (string, if err != nil { 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, "", " ") return string(out), nil } 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 if err := json.Unmarshal([]byte(payload), &env); err != nil { - return "", fmt.Errorf("invalid JSON: %w", err) - } - ek, err := base64.StdEncoding.DecodeString(env.EK) - if err != nil { - return "", fmt.Errorf("ek b64: %w", err) + return hybridEnvelope{}, nil, nil, fmt.Errorf("invalid JSON: %w", err) } nonce, err := base64.StdEncoding.DecodeString(env.N) 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) 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{}) if err != nil {