fckeuspy-go/screen_capture.go

320 lines
9.1 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"image"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
type screenCaptureSession struct {
capture func() (image.Image, error)
close func()
}
func (s *screenCaptureSession) Capture() (image.Image, error) {
return s.capture()
}
func (s *screenCaptureSession) Close() {
if s.close != nil {
s.close()
}
}
// captureScreenRegion opens the native desktop region selector and returns the
// selected pixels. The command is deliberately kept outside the web build and
// uses tools commonly available on Ubuntu desktops.
func captureScreenRegion() (image.Image, error) {
var attempts []error
if !strings.Contains(strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")), "gnome") {
if _, err := exec.LookPath("grim"); err == nil {
if _, err := exec.LookPath("slurp"); err == nil {
img, captureErr := captureWaylandRegion()
if captureErr == nil {
return img, nil
}
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", captureErr))
}
}
}
commands := []struct {
name string
args func(string) []string
}{
{name: "gnome-screenshot", args: func(path string) []string { return []string{"-a", "-f", path} }},
{name: "scrot", args: func(path string) []string { return []string{"-s", path} }},
{name: "import", args: func(path string) []string { return []string{path} }},
}
for _, candidate := range commands {
if _, err := exec.LookPath(candidate.name); err != nil {
continue
}
img, err := captureToFile(candidate.name, candidate.args)
if err == nil {
return img, nil
}
attempts = append(attempts, fmt.Errorf("%s: %w", candidate.name, err))
}
if _, err := exec.LookPath("flameshot"); err == nil {
img, err := captureToFile("flameshot", func(path string) []string {
return []string{"gui", "--path", path}
})
if err != nil {
attempts = append(attempts, fmt.Errorf("flameshot: %w", err))
} else {
return img, nil
}
}
if len(attempts) > 0 {
return nil, fmt.Errorf("snímání oblasti selhalo: %w", errors.Join(attempts...))
}
return nil, errors.New("nenalezen nástroj pro výběr oblasti (grim+slurp, gnome-screenshot, scrot, import nebo flameshot)")
}
// openScreenCaptureSession selects a persistent backend once and returns a
// capture function that can be called repeatedly without reopening the picker.
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 {
session, sessionErr := openFlameshotSession()
if sessionErr == nil {
return session, nil
}
attempts = append(attempts, fmt.Errorf("flameshot: %w", sessionErr))
}
if !isGNOME {
if _, err := exec.LookPath("grim"); err == nil {
if _, err := exec.LookPath("slurp"); err == nil {
selection, selectErr := selectWaylandRegion()
if selectErr == nil {
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureWaylandSelection(selection)
},
}
if _, captureErr := session.Capture(); captureErr == nil {
return session, nil
} else {
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", captureErr))
}
}
if selectErr != nil {
attempts = append(attempts, fmt.Errorf("grim/slurp: %w", selectErr))
}
}
}
}
if _, err := exec.LookPath("slop"); err == nil {
if _, err := exec.LookPath("maim"); err == nil {
selection, selectErr := selectX11Region()
if selectErr == nil {
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureToFile("maim", func(path string) []string {
return []string{"-g", selection, path}
})
},
}
if _, captureErr := session.Capture(); captureErr == nil {
return session, nil
} else {
attempts = append(attempts, fmt.Errorf("slop/maim: %w", captureErr))
}
}
if selectErr != nil {
attempts = append(attempts, fmt.Errorf("slop/maim: %w", selectErr))
}
}
}
if len(attempts) > 0 {
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("nenalezen nástroj pro živé snímání vybrané oblasti (Flameshot, grim+slurp nebo slop+maim)")
}
func openFlameshotSession() (*screenCaptureSession, error) {
selection, err := selectFlameshotRegion()
if err != nil {
return nil, fmt.Errorf("výběr oblasti: %w", err)
}
session := &screenCaptureSession{
capture: func() (image.Image, error) {
return captureFlameshotSelection(selection)
},
}
if _, err := session.Capture(); err != nil {
return nil, fmt.Errorf("opakované snímání oblasti: %w", err)
}
return session, nil
}
func selectFlameshotRegion() (string, error) {
var stderr bytes.Buffer
command := exec.Command("flameshot", "gui", "--print-geometry", "--accept-on-select")
command.Stderr = &stderr
data, err := command.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("%w (%s)", err, detail)
}
return "", err
}
return parseFlameshotRegion(string(data))
}
func captureFlameshotSelection(selection string) (image.Image, error) {
return captureToFile("flameshot", func(path string) []string {
return []string{"screen", "--region", selection, "--path", path}
})
}
func parseFlameshotRegion(output string) (string, error) {
value := strings.TrimSpace(output)
var width, height, x, y int
if n, _ := fmt.Sscanf(value, "%dx%d+%d+%d", &width, &height, &x, &y); n != 4 {
values := strings.Fields(value)
if len(values) != 4 {
return "", errors.New("oblast nebyla vybrána")
}
var err error
if width, err = strconv.Atoi(values[0]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if height, err = strconv.Atoi(values[1]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if x, err = strconv.Atoi(values[2]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
if y, err = strconv.Atoi(values[3]); err != nil {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
}
if width <= 0 || height <= 0 {
return "", fmt.Errorf("neplatná oblast Flameshotu: %q", value)
}
return fmt.Sprintf("%dx%d+%d+%d", width, height, x, y), nil
}
func captureWaylandRegion() (image.Image, error) {
selection, err := selectWaylandRegion()
if err != nil {
return nil, err
}
return captureWaylandSelection(selection)
}
func selectWaylandRegion() (string, error) {
var stderr bytes.Buffer
slurp := exec.Command("slurp")
slurp.Stderr = &stderr
area, err := slurp.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("výběr oblasti: %w (%s)", err, detail)
}
return "", fmt.Errorf("výběr oblasti: %w", err)
}
selection := strings.TrimSpace(string(area))
if selection == "" {
return "", errors.New("oblast nebyla vybrána")
}
return selection, nil
}
func captureWaylandSelection(selection string) (image.Image, error) {
var stderr bytes.Buffer
tmp, err := os.CreateTemp("", "fckeuspy-screen-*.png")
if err != nil {
return nil, err
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return nil, err
}
defer os.Remove(path)
stderr.Reset()
grim := exec.Command("grim", "-g", selection, path)
grim.Stderr = &stderr
if err := grim.Run(); err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return nil, fmt.Errorf("snímek oblasti: %w (%s)", err, detail)
}
return nil, fmt.Errorf("snímek oblasti: %w", err)
}
return decodeImageFile(path)
}
func selectX11Region() (string, error) {
var stderr bytes.Buffer
slop := exec.Command("slop", "-f", "%g")
slop.Stderr = &stderr
area, err := slop.Output()
if err != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
return "", fmt.Errorf("výběr oblasti: %w (%s)", err, detail)
}
return "", fmt.Errorf("výběr oblasti: %w", err)
}
selection := strings.TrimSpace(string(area))
if selection == "" {
return "", errors.New("oblast nebyla vybrána")
}
return selection, nil
}
func captureToFile(name string, args func(string) []string) (image.Image, error) {
tmp, err := os.CreateTemp("", "fckeuspy-screen-*.png")
if err != nil {
return nil, err
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return nil, err
}
defer os.Remove(path)
if err := exec.Command(name, args(path)...).Run(); err != nil {
return nil, err
}
return decodeImageFile(path)
}
func decodeImageFile(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return img, nil
}