projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
240
agent/internal/desktop/manager.go
Normal file
240
agent/internal/desktop/manager.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// Package desktop manages the native Qt/QML local-machine operations console.
|
||||
// The agent service spawns the desktop binary as a child process when a desktop
|
||||
// session is available. The binary connects back to the agent's local API socket
|
||||
// and presents Agent-owned machine state and bounded operator intent.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager handles spawning and monitoring the desktop app process.
|
||||
type Manager struct {
|
||||
binPath string
|
||||
enabled bool
|
||||
maxRestarts int
|
||||
restartDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
stopped bool
|
||||
lastHealth healthReport
|
||||
}
|
||||
|
||||
// healthReport is Desktop's most recent POST /v1/desktop self-report. On
|
||||
// Linux Desktop is launched by XDG autostart, not by this manager, so the
|
||||
// spawned-process state is always empty there — the health report is the only
|
||||
// liveness and version signal the agent has.
|
||||
type healthReport struct {
|
||||
version string
|
||||
windowOpen bool
|
||||
reportedAt time.Time
|
||||
}
|
||||
|
||||
// healthFreshness is how long a health report counts as proof of a live Desktop.
|
||||
// Desktop reports every 30s; three missed beats means it is gone.
|
||||
const healthFreshness = 90 * time.Second
|
||||
|
||||
// HealthSnapshot is the fleet-reportable desktop component state.
|
||||
type HealthSnapshot struct {
|
||||
Installed bool `json:"installed"`
|
||||
Running bool `json:"running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
WindowOpen bool `json:"window_open,omitempty"`
|
||||
LastReport string `json:"last_report,omitempty"`
|
||||
}
|
||||
|
||||
// NewManager creates a desktop manager. binPath is the path to the redflag-desktop
|
||||
// binary (empty string = auto-detect alongside the agent binary).
|
||||
func NewManager(binPath string, enabled bool, maxRestarts, restartDelaySec int) *Manager {
|
||||
if binPath == "" {
|
||||
binPath = autoDetectBinary()
|
||||
}
|
||||
if restartDelaySec <= 0 {
|
||||
restartDelaySec = 5
|
||||
}
|
||||
return &Manager{
|
||||
binPath: binPath,
|
||||
enabled: enabled,
|
||||
maxRestarts: maxRestarts,
|
||||
restartDelay: time.Duration(restartDelaySec) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// autoDetectBinary finds the desktop binary alongside the agent binary.
|
||||
func autoDetectBinary() string {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(execPath)
|
||||
name := "redflag-desktop"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "redflag-desktop.exe"
|
||||
}
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
|
||||
// IsAvailable checks if the desktop binary exists and a desktop session is detectable.
|
||||
func (m *Manager) IsAvailable() bool {
|
||||
if m.binPath == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(m.binPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
return hasDesktopSession()
|
||||
}
|
||||
|
||||
// Start begins managing the desktop process. It blocks until Stop() is called
|
||||
// or the context is cancelled. Spawns the binary, restarts on crash up to
|
||||
// maxRestarts times.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
if !m.enabled {
|
||||
log.Printf("[INFO] [agent] [desktop] disabled by config")
|
||||
return
|
||||
}
|
||||
|
||||
if !m.IsAvailable() {
|
||||
log.Printf("[INFO] [agent] [desktop] not available — binary=%s session_detected=false", m.binPath)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [desktop] starting binary=%s", m.binPath)
|
||||
|
||||
m.mu.Lock()
|
||||
ctx, m.cancel = context.WithCancel(ctx)
|
||||
m.stopped = false
|
||||
m.mu.Unlock()
|
||||
|
||||
restarts := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[INFO] [agent] [desktop] stopped (context cancelled)")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if m.maxRestarts > 0 && restarts >= m.maxRestarts {
|
||||
log.Printf("[WARNING] [agent] [desktop] max restarts reached (%d), giving up", m.maxRestarts)
|
||||
return
|
||||
}
|
||||
|
||||
err := m.run(ctx)
|
||||
if err == nil {
|
||||
// Clean exit — don't restart.
|
||||
log.Printf("[INFO] [agent] [desktop] exited cleanly")
|
||||
return
|
||||
}
|
||||
|
||||
restarts++
|
||||
log.Printf("[WARNING] [agent] [desktop] process exited with error: %v (restart %d)", err, restarts)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.restartDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run spawns the desktop binary and waits for it to exit.
|
||||
func (m *Manager) run(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
cmd := exec.CommandContext(ctx, m.binPath)
|
||||
m.cmd = cmd
|
||||
m.mu.Unlock()
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start desktop: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [desktop] spawned pid=%d", cmd.Process.Pid)
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// Stop terminates the desktop process if running.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.stopped = true
|
||||
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
log.Printf("[INFO] [agent] [desktop] terminating pid=%d", m.cmd.Process.Pid)
|
||||
m.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns current desktop process state.
|
||||
func (m *Manager) Status() (running bool, pid int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cmd == nil || m.cmd.Process == nil {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Check if process is still alive.
|
||||
if m.cmd.ProcessState != nil && m.cmd.ProcessState.Exited() {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, m.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// RecordHealth stores Desktop's self-report (POST /v1/desktop via localapi).
|
||||
func (m *Manager) RecordHealth(version string, windowOpen bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lastHealth = healthReport{
|
||||
version: version,
|
||||
windowOpen: windowOpen,
|
||||
reportedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns the desktop component state for fleet reporting. Running is
|
||||
// true when this manager spawned a live process (Windows) or when a health
|
||||
// report landed within the freshness window (Linux autostart path).
|
||||
func (m *Manager) Health() HealthSnapshot {
|
||||
running, _ := m.Status()
|
||||
|
||||
m.mu.Lock()
|
||||
last := m.lastHealth
|
||||
m.mu.Unlock()
|
||||
|
||||
snap := HealthSnapshot{Running: running}
|
||||
if m.binPath != "" {
|
||||
if info, err := os.Stat(m.binPath); err == nil && !info.IsDir() {
|
||||
snap.Installed = true
|
||||
}
|
||||
}
|
||||
if !last.reportedAt.IsZero() {
|
||||
snap.Version = last.version
|
||||
snap.WindowOpen = last.windowOpen
|
||||
snap.LastReport = last.reportedAt.Format(time.RFC3339)
|
||||
if time.Since(last.reportedAt) <= healthFreshness {
|
||||
snap.Running = true
|
||||
}
|
||||
}
|
||||
return snap
|
||||
}
|
||||
55
agent/internal/desktop/session_linux.go
Normal file
55
agent/internal/desktop/session_linux.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on Linux.
|
||||
// Looks for DISPLAY or WAYLAND_DISPLAY in the environment, which indicates
|
||||
// an X11 or Wayland session is active.
|
||||
func hasDesktopSession() bool {
|
||||
// Check standard environment variables.
|
||||
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: scan /proc for a running desktop session (the agent service
|
||||
// may not inherit DISPLAY from systemd).
|
||||
return findDesktopSessionInProc()
|
||||
}
|
||||
|
||||
// findDesktopSessionInProc scans /proc/*/environ for DISPLAY or WAYLAND_DISPLAY.
|
||||
// Returns true if any user process has a desktop session active.
|
||||
func findDesktopSessionInProc() bool {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Only check numeric (PID) directories.
|
||||
if entry.Name()[0] < '0' || entry.Name()[0] > '9' {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range strings.Split(string(data), "\x00") {
|
||||
if strings.HasPrefix(v, "DISPLAY=") && len(v) > 8 {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(v, "WAYLAND_DISPLAY=") && len(v) > 17 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
11
agent/internal/desktop/session_other.go
Normal file
11
agent/internal/desktop/session_other.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !linux && !windows
|
||||
|
||||
package desktop
|
||||
|
||||
import "os"
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on other platforms.
|
||||
// Default: check for DISPLAY environment variable.
|
||||
func hasDesktopSession() bool {
|
||||
return os.Getenv("DISPLAY") != ""
|
||||
}
|
||||
27
agent/internal/desktop/session_windows.go
Normal file
27
agent/internal/desktop/session_windows.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package desktop
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hasDesktopSession checks if a desktop session is available on Windows.
|
||||
// Looks for an active console session via query session.
|
||||
func hasDesktopSession() bool {
|
||||
// query session lists all sessions. An active console session with
|
||||
// a logged-in user means a desktop is available.
|
||||
out, err := exec.Command("query", "session").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
// Active console sessions have "Active" in the state column.
|
||||
if strings.Contains(line, "Active") && strings.Contains(line, "console") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in a new issue