fix: screenshot square inside System Information card; display discovery
UI: move screenshot/Sunshine square into the System Information card header (top-right, w-48 aspect-video) instead of a standalone block above the card. Same click logic, smaller size to fit the header row. Agent: captureScreenLinux now discovers DISPLAY/WAYLAND_DISPLAY/ XDG_RUNTIME_DIR from /proc environ entries so the service (which doesn't inherit display vars from systemd) can reach the session. Tool priority: scrot → grim → magick import → import. Adds bytes/strconv/strings imports for discoverSessionDisplayEnv.
This commit is contained in:
parent
99246ecf33
commit
80e719acc9
2 changed files with 156 additions and 119 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
|
@ -9,6 +10,8 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -69,47 +72,94 @@ func captureScreen(outputPath string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// captureScreenLinux captures the display using scrot (X11) with import
|
||||
// (ImageMagick) as fallback. Both are read-only and do not interact with
|
||||
// the display server.
|
||||
// captureScreenLinux captures the display. The agent service does not inherit
|
||||
// DISPLAY/WAYLAND_DISPLAY from systemd, so we discover them from the running
|
||||
// user session before invoking any capture tool.
|
||||
//
|
||||
// Tool priority: scrot (X11) → grim (Wayland) → magick import (X11 fallback).
|
||||
func captureScreenLinux(outputPath string) error {
|
||||
// Try scrot first — most common on Fedora/Ubuntu with X11.
|
||||
if _, err := exec.LookPath("scrot"); err == nil {
|
||||
sessionEnv := discoverSessionDisplayEnv()
|
||||
env := append(os.Environ(), sessionEnv...)
|
||||
|
||||
tryCmd := func(name string, args ...string) bool {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "scrot", "-o", outputPath)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] scrot_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
log.Printf("[WARN] [agent] [screenshot] %s_failed output=%q error=%v", name, string(out), err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: ImageMagick import — also X11, widely available.
|
||||
if _, err := exec.LookPath("import"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "import", "-window", "root", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] import_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("scrot", "-o", outputPath) {
|
||||
return nil
|
||||
}
|
||||
if tryCmd("grim", outputPath) {
|
||||
return nil
|
||||
}
|
||||
// ImageMagick v7: standalone `import` is gone, use `magick import`.
|
||||
if tryCmd("magick", "import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
// ImageMagick v6 compat.
|
||||
if tryCmd("import", "-window", "root", outputPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: grim (Wayland native).
|
||||
if _, err := exec.LookPath("grim"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "grim", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("[WARN] [agent] [screenshot] grim_failed output=%q error=%v", string(out), err)
|
||||
} else {
|
||||
return nil
|
||||
return fmt.Errorf("no screenshot tool found (tried scrot, grim, magick import)")
|
||||
}
|
||||
|
||||
// discoverSessionDisplayEnv reads /proc environ entries to find DISPLAY,
|
||||
// WAYLAND_DISPLAY, and XDG_RUNTIME_DIR from an active user session.
|
||||
// The agent service runs without these inherited from systemd.
|
||||
func discoverSessionDisplayEnv() []string {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(entry.Name()); err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var display, wayland, xdgRuntime string
|
||||
for _, v := range bytes.Split(data, []byte{0}) {
|
||||
s := string(v)
|
||||
switch {
|
||||
case strings.HasPrefix(s, "DISPLAY=") && display == "":
|
||||
display = s
|
||||
case strings.HasPrefix(s, "WAYLAND_DISPLAY=") && wayland == "":
|
||||
wayland = s
|
||||
case strings.HasPrefix(s, "XDG_RUNTIME_DIR=") && xdgRuntime == "":
|
||||
xdgRuntime = s
|
||||
}
|
||||
}
|
||||
if display != "" || wayland != "" {
|
||||
var out []string
|
||||
if display != "" {
|
||||
out = append(out, display)
|
||||
}
|
||||
if wayland != "" {
|
||||
out = append(out, wayland)
|
||||
}
|
||||
if xdgRuntime != "" {
|
||||
out = append(out, xdgRuntime)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no screenshot tool found (tried scrot, import, grim)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureScreenWindows captures the display using PowerShell + .NET
|
||||
|
|
|
|||
|
|
@ -816,97 +816,84 @@ const Agents: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Screen square — screenshot capture / Sunshine connection link */}
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const running = state === 'running';
|
||||
const sunshineReady = live || running;
|
||||
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
|
||||
// Determine what the square shows
|
||||
const hasImage = !!screenshotImage;
|
||||
const squareClickable = sunshineReady || !isCapturing;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full max-w-sm overflow-hidden rounded border',
|
||||
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
|
||||
'flex flex-col items-center justify-center gap-2 text-slate-400',
|
||||
squareClickable && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (sunshineReady && sunshine?.web_ui) {
|
||||
window.open(sunshine.web_ui, '_blank', 'noreferrer');
|
||||
} else if (!isCapturing && !isPolling) {
|
||||
handleCaptureScreenshot(selectedAgent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${screenshotImage}`}
|
||||
alt="Agent screenshot"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MonitorPlay className="h-10 w-10 opacity-70" />
|
||||
<span className="text-xs">
|
||||
{isCapturing ? 'Requesting...'
|
||||
: isPolling ? 'Capturing...'
|
||||
: sunshineReady ? 'Open stream host'
|
||||
: 'Click to capture'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIVE marker when Sunshine session is active */}
|
||||
{live && (
|
||||
<span className="absolute top-2 left-2 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sunshine status badge */}
|
||||
{sunshineReady && (
|
||||
<span className="absolute top-2 right-2 rounded bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
{live ? sunshine?.client_name || 'Connected' : 'Sunshine running'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Loading spinner overlay */}
|
||||
{(isCapturing || isPolling) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<RefreshCw className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption row beneath the square */}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||
{sunshine?.version && <span>Sunshine v{sunshine.version}</span>}
|
||||
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at && (
|
||||
<span className="ml-auto">Captured {formatRelativeTime(screenshotCommand.completed_at)}</span>
|
||||
)}
|
||||
{screenshotCommand?.status === 'failed' && (
|
||||
<span className="text-red-500 ml-auto">Capture failed</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* System info */}
|
||||
{/* System info — screenshot square lives in the card header */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">System Information</h2>
|
||||
{(() => {
|
||||
const integrations = readIntegrations(selectedAgent.metadata);
|
||||
const sunshine = integrations.sunshine;
|
||||
const state = resolveState(sunshine);
|
||||
const live = state === 'active';
|
||||
const sunshineReady = live || state === 'running';
|
||||
const isCapturing = captureScreenshotMutation.isPending;
|
||||
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
|
||||
const hasImage = !!screenshotImage;
|
||||
const canClick = !isCapturing && !isPolling;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-4 gap-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">System Information</h2>
|
||||
<div className="shrink-0 w-48">
|
||||
<div
|
||||
className={cn(
|
||||
'relative aspect-video w-full overflow-hidden rounded border',
|
||||
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
|
||||
'flex flex-col items-center justify-center gap-1 text-slate-400',
|
||||
canClick && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (sunshineReady && sunshine?.web_ui) {
|
||||
window.open(sunshine.web_ui, '_blank', 'noreferrer');
|
||||
} else if (canClick) {
|
||||
handleCaptureScreenshot(selectedAgent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${screenshotImage}`}
|
||||
alt="Agent screenshot"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MonitorPlay className="h-6 w-6 opacity-70" />
|
||||
<span className="text-[10px]">
|
||||
{isCapturing ? 'Requesting…'
|
||||
: isPolling ? 'Capturing…'
|
||||
: sunshineReady ? 'Open stream'
|
||||
: 'Click to capture'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{live && (
|
||||
<span className="absolute top-1 left-1 flex items-center gap-1 rounded bg-red-600/90 px-1 py-0.5 text-[9px] font-medium text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
{sunshineReady && !live && (
|
||||
<span className="absolute top-1 right-1 rounded bg-black/50 px-1 py-0.5 text-[9px] text-white">
|
||||
Sunshine
|
||||
</span>
|
||||
)}
|
||||
{(isCapturing || isPolling) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<RefreshCw className="h-5 w-5 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1 text-[10px] text-gray-500">
|
||||
{sunshine?.version ? <span>Sunshine v{sunshine.version}</span> : <span />}
|
||||
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at
|
||||
? <span>{formatRelativeTime(screenshotCommand.completed_at)}</span>
|
||||
: screenshotCommand?.status === 'failed'
|
||||
? <span className="text-red-500">Failed</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic System Info */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue