// Package screenshot erzeugt periodisch Screenshots des aktuell angezeigten Inhalts // und sendet sie an den Backend-Server (Phase 6). // // Strategie (in dieser Reihenfolge): // 1. scrot -z -q 60 /tmp/morz-screenshot.jpg — leichtgewichtig, für X11 // 2. import -window root /tmp/morz-screenshot.png — ImageMagick, falls scrot fehlt // 3. xwd -root -silent | convert xwd:- /tmp/morz-screenshot.jpg — Fallback // // Der Screenshot wird per HTTP MULTIPART POST an // POST /api/v1/player/screenshot gesendet. package screenshot import ( "bytes" "context" "fmt" "log" "mime/multipart" "net/http" "os" "os/exec" "path/filepath" "time" ) const ( screenshotPath = "/tmp/morz-screenshot.jpg" defaultInterval = 60 * time.Second uploadTimeout = 15 * time.Second screenshotQuality = "60" // JPEG quality (0-100) ) // Screenshotter erzeugt periodisch Screenshots und sendet sie an den Server. type Screenshotter struct { screenID string serverBaseURL string interval time.Duration logger *log.Logger } // New erzeugt einen neuen Screenshotter. func New(screenID, serverBaseURL string, intervalSeconds int, logger *log.Logger) *Screenshotter { interval := defaultInterval if intervalSeconds > 0 { interval = time.Duration(intervalSeconds) * time.Second } if logger == nil { logger = log.New(os.Stdout, "screenshot ", log.LstdFlags|log.LUTC) } return &Screenshotter{ screenID: screenID, serverBaseURL: serverBaseURL, interval: interval, logger: logger, } } // Run startet die periodische Screenshot-Schleife und blockiert bis ctx abgebrochen wird. func (s *Screenshotter) Run(ctx context.Context) { ticker := time.NewTicker(s.interval) defer ticker.Stop() // Erster Screenshot nach kurzem Delay (damit Chromium hochgefahren ist). select { case <-ctx.Done(): return case <-time.After(10 * time.Second): } s.takeAndSend(ctx) for { select { case <-ctx.Done(): return case <-ticker.C: s.takeAndSend(ctx) } } } // takeAndSend erzeugt einen Screenshot und sendet ihn an den Server. func (s *Screenshotter) takeAndSend(ctx context.Context) { path, err := s.capture() if err != nil { s.logger.Printf("event=screenshot_capture_failed screen_id=%s err=%v", s.screenID, err) return } defer os.Remove(path) //nolint:errcheck if err := s.upload(ctx, path); err != nil { s.logger.Printf("event=screenshot_upload_failed screen_id=%s err=%v", s.screenID, err) return } s.logger.Printf("event=screenshot_sent screen_id=%s", s.screenID) } // capture erzeugt einen Screenshot mit dem ersten verfügbaren Tool. func (s *Screenshotter) capture() (string, error) { // Aufräumen falls eine alte Datei existiert. os.Remove(screenshotPath) //nolint:errcheck // Versuch 1: scrot (leichtgewichtig, für X11) if path, err := tryScrot(); err == nil { return path, nil } // Versuch 2: import (ImageMagick) if path, err := tryImport(); err == nil { return path, nil } // Versuch 3: xwd + convert if path, err := tryXwd(); err == nil { return path, nil } return "", fmt.Errorf("keine Screenshot-Tool verfügbar (scrot, import, xwd)") } func tryScrot() (string, error) { cmd := exec.Command("scrot", "-z", "-q", screenshotQuality, screenshotPath) if err := cmd.Run(); err != nil { return "", err } return screenshotPath, nil } func tryImport() (string, error) { // ImageMagick import: -window root macht einen Screenshot des gesamten X-Displays. pngPath := "/tmp/morz-screenshot-tmp.png" cmd := exec.Command("import", "-window", "root", pngPath) if err := cmd.Run(); err != nil { return "", err } // Zu JPEG konvertieren. cmd = exec.Command("convert", pngPath, "-quality", screenshotQuality, screenshotPath) defer os.Remove(pngPath) //nolint:errcheck if err := cmd.Run(); err != nil { return "", err } return screenshotPath, nil } func tryXwd() (string, error) { xwdPath := "/tmp/morz-screenshot-tmp.xwd" // xwd schreibt in Datei. xwdCmd := exec.Command("xwd", "-root", "-silent", "-out", xwdPath) if err := xwdCmd.Run(); err != nil { return "", err } defer os.Remove(xwdPath) //nolint:errcheck // convert xwd -> jpg. cmd := exec.Command("convert", "xwd:"+xwdPath, "-quality", screenshotQuality, screenshotPath) if err := cmd.Run(); err != nil { return "", err } return screenshotPath, nil } // upload sendet den Screenshot per MULTIPART POST an den Server. func (s *Screenshotter) upload(ctx context.Context, path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read screenshot: %w", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) _ = writer.WriteField("screen_id", s.screenID) ext := filepath.Ext(path) mimeType := "image/jpeg" if ext == ".png" { mimeType = "image/png" } fw, err := writer.CreateFormFile("screenshot", "screenshot"+ext) if err != nil { return fmt.Errorf("create form file: %w", err) } if _, err := fw.Write(data); err != nil { return fmt.Errorf("write form file: %w", err) } _ = writer.WriteField("mime_type", mimeType) writer.Close() uploadCtx, cancel := context.WithTimeout(ctx, uploadTimeout) defer cancel() req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, s.serverBaseURL+"/api/v1/player/screenshot", &body, ) if err != nil { return err } req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("server returned %d", resp.StatusCode) } return nil }