feat(live chat): funkcni live snimani chatu a zobrzovani desifrovanych zprav

This commit is contained in:
Lukas Batelka 2026-07-19 01:36:04 +02:00
parent 679a1e0776
commit 7e6c859856
4 changed files with 262 additions and 5 deletions

2
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.0
require (
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/makiuchi-d/gozxing v0.1.0
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-text/render v0.2.0 // 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/safejs v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect

248
portal_capture.go Normal file
View File

@ -0,0 +1,248 @@
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())
})
return decodeImageFile(filepath.Join(s.dir, entries[0].Name()))
}
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

@ -86,8 +86,13 @@ func captureScreenRegion() (image.Image, error) {
func openScreenCaptureSession() (*screenCaptureSession, error) {
var attempts []error
isGNOME := strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome")
if _, err := exec.LookPath("flameshot"); err == nil {
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
@ -145,7 +150,7 @@ func openScreenCaptureSession() (*screenCaptureSession, error) {
return nil, fmt.Errorf("živé snímání oblasti selhalo: %w", errors.Join(attempts...))
}
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)")
}

6
ui.go
View File

@ -1078,7 +1078,11 @@ func buildChatScannerTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
stopLive = nil
status.SetText("Živé sledování zastaveno.")
}
status.SetText("Vybírám oblast pro živé sledování…")
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 {