Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/desktop/manager.go
Fimeg ff2f30f47a v0.2.9.3: device classification + ARM support — Pixel 3 lands
DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id
combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak
hostname fallback. Hardware-bound IDs on DMI-less devices.

DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from
/sys signals — system battery (scope=Device peripherals excluded, UPS
excluded), DRM connector state, framebuffer min-dimension for phone/tablet
split. Reports device_type/device_model/os_distro in registration and
system-info paths.

SERVER-001: migration 061 — device_type, device_type_manual (operator
override, never agent-written), device_model, os_distro on agents.
effective_device_type computed into every serialized agent.

SERVER-002: PUT /admin/agents/:id/device-type — set/clear override,
enum-validated, journaled.

WEB-001: device-type icons + fleet filter, device model in list, detail
header badge with reclassify dropdown, os_distro surfaced.

INSTALL-003: arm64 install path unblocked — helper (required manifest
component) now cross-built aarch64-unknown-linux-musl via rust-lld in the
server image, signed at boot (helperArches += arm64), listed in the release
manifest. Install template already handled uname -m and pacman.

Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow
updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
2026-07-06 18:21:23 -04:00

240 lines
6 KiB
Go

// Package desktop manages the Tauri desktop app (system tray + local UI shell).
// 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 provides a system tray icon with a local dashboard.
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 the tray's most recent POST /v1/desktop self-report. On
// Linux the tray 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 tray.
// The tray 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 the tray'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
}