From 7e6c8598568f99d425cfecca71f808be8f015c2c Mon Sep 17 00:00:00 2001 From: Lukas Batelka Date: Sun, 19 Jul 2026 01:36:04 +0200 Subject: [PATCH] feat(live chat): funkcni live snimani chatu a zobrzovani desifrovanych zprav --- go.mod | 2 +- portal_capture.go | 248 ++++++++++++++++++++++++++++++++++++++++++++++ screen_capture.go | 11 +- ui.go | 6 +- 4 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 portal_capture.go diff --git a/go.mod b/go.mod index 2f4283e..bf49515 100755 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/portal_capture.go b/portal_capture.go new file mode 100644 index 0000000..6c75b36 --- /dev/null +++ b/portal_capture.go @@ -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) +} diff --git a/screen_capture.go b/screen_capture.go index b941c85..74cec92 100644 --- a/screen_capture.go +++ b/screen_capture.go @@ -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)") } diff --git a/ui.go b/ui.go index 495e9e3..80e29dc 100755 --- a/ui.go +++ b/ui.go @@ -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 {