Watch
1
0
Fork
You've already forked RedFlag
0

desktop: become the machine console

Qt/QML now carries 11 local views while the Agent owns observation and intent. Tauri, WebKit, and the second React desktop build leave together. Linux ships first; Windows waits for a native Qt runner.
This commit is contained in:
Fimeg 2026-09-01 08:29:58 -04:00
commit f487de554a
61 changed files with 4475 additions and 10471 deletions

View file

@ -133,23 +133,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install Tauri system dependencies
- name: Install Qt 6 build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
- name: Check RedFlag desktop
run: |
cd web
npm ci
npm run build:desktop
cd ../desktop
cargo check
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
- name: Check native RedFlag Desktop
run: cd desktop && cargo check --locked
installer-integrity:
runs-on: ubuntu-latest

View file

@ -53,11 +53,8 @@ jobs:
FAIL=1
fi
# Desktop: optional component, but if present, its version must match.
# desktop/Cargo.toml is the single source of the desktop version (3-part
# semver); tauri.conf.json carries no version field and inherits it from
# the crate (a 4-octet there is invalid semver and Tauri's build refuses
# it), so there is nothing to cross-check on tauri.conf.json.
# Desktop: optional component, but if present, its Rust/Qt crate version
# must match the first three tag octets.
if [ -f desktop/Cargo.toml ]; then
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
@ -345,23 +342,19 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT}
- name: Build desktop (Tauri system tray)
- name: Build native Qt/QML Desktop
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# Tauri v2 system dependencies (webkit2gtk-4.1 for Ubuntu 24.04+).
# CXX-Qt compiles the Rust bridge and embeds the QML module. The web
# application is not an input to the native Desktop binary.
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 2>/dev/null || true
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
# Build the desktop frontend (Tauri's beforeBuildCommand, but we do it
# explicitly so the web build is deterministic).
cd web && npm ci --silent && npm run build:desktop && cd ..
# Build the desktop binary.
cd desktop
cargo build --release
cargo build --release --locked
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }})"
@ -370,40 +363,13 @@ jobs:
echo "Desktop version: $DESKTOP_VER"
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; }
- name: Build desktop (Tauri system tray - Windows cross-compile)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# Tauri v2 host build scripts need webkit2gtk even when cross-compiling.
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev clang lld llvm 2>/dev/null || true
# Install cargo-xwin for MSVC cross-compilation.
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
# Build the desktop frontend.
cd web && npm ci --silent && npm run build:desktop && cd ..
# Cross-compile the desktop binary (bundling disabled — raw exe).
cd desktop
cargo xwin build --release --target x86_64-pc-windows-msvc
cp target/x86_64-pc-windows-msvc/release/redflag-desktop.exe \
../dist/redflag-desktop-${{ matrix.suffix }}.exe
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }}.exe)"
# Cross-compiled binary can't run here for version check, but the gate
# job validates the manifest hash before publish.
- name: Build Windows installer (RedFlagSetup.msi)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# MSI's ProductVersion only carries 3 significant fields for
# upgrade detection — same 3-vs-4-part reconciliation bump-version.sh
# already does for desktop/Cargo.toml vs tauri.conf.json.
# MSI's ProductVersion only carries 3 significant fields for upgrade
# detection; bump-version.sh applies the same 3-vs-4-part mapping.
WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3)
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely

View file

@ -38,10 +38,10 @@ logs: ## Tail the server logs
prune-cruft: ## Remove retired RedFlag images (redflag-web, desktop-stage-test)
-docker image rm redflag-web:latest redflag-desktop-stage-test:latest 2>/dev/null; true
fetch-desktop-windows: ## Fetch+verify the signed Windows tray from the latest release into ./dist
fetch-desktop-windows: ## Fetch+verify the signed Windows Desktop from the latest release into ./dist
@mkdir -p dist
sh scripts/fetch-desktop-windows.sh amd64 ./dist
@ls -lh dist/redflag-desktop.exe 2>/dev/null || echo "no Windows tray in the latest release yet"
@ls -lh dist/redflag-desktop.exe 2>/dev/null || echo "no Windows Desktop in the latest release yet"
db-up: ## Start PostgreSQL database
docker-compose up -d postgres

View file

@ -1,6 +1,6 @@
# RedFlag
**Self-hosted update management for operators who own their stack.**
**Understand and govern the machines you own.**
`v0.2.9.3` — July 2026 · AGPL-3.0
@ -14,7 +14,9 @@
<!-- Showcase video goes here: terminal → install → multiple agents → updates. -->
One dashboard for updates across Linux, Windows, and the Docker containers running on those hosts. Agents check in, scan their package managers, and queue what they find. Nothing installs until a human approves it.
RedFlag is a local-machine operations console and a self-hosted fleet authority. The native Qt/QML Desktop asks what this computer is doing now: health, resources, processes, connections, services, containers, installed software, updates, security evidence, and history. RedFlag Web asks the same questions across many machines.
The Agent owns observation and machine state. Desktop expresses operator intent; it does not shell out to package managers, service managers, or Docker. Privileged mutation crosses RedFlag's signed authority path or it does not happen.
What makes RedFlag different: the software that patches your fleet runs as root on every box, which makes it part of your attack surface — XZ Utils came through a build pipeline, SolarWinds came through an update. So every command here is Ed25519-signed and agents reject anything forged or replayed. On APT and DNF, direct package mutation must cross a privileged Rust helper: a short-lived capability binds the host, operation, and artifact entries whose hashes resolved, and the helper validates that authority before executing a fixed argv plan with a cleared environment. Docker, Winget, and Windows Update still use the default-strict signed-command path. The full trust model is in [SECURITY.md](SECURITY.md).
@ -41,6 +43,23 @@ ConnectWise charges $50/agent/month. RedFlag doesn't.
---
## RedFlag Desktop
Desktop is the native view of this machine, not a web dashboard wrapped in a window. It is built in Rust with Qt/QML and talks to the Agent over a local Unix socket or Windows named pipe.
The current Linux cut includes:
- live CPU, load, memory, swap, network, storage, and thermal history
- process inventory and drill-down with sockets, namespaces, capabilities, service/container ownership, and owning package
- systemd services, Docker containers and Compose stacks
- installed pacman, dpkg/APT, and RPM/DNF software inventory with dependency and file detail
- available updates, advisories, policy evidence, approval, and recorded override intent
- security posture and durable local history
The Windows local transport exists, but a Windows Desktop artifact waits for a native Qt/MSVC release runner. The web application remains the fleet surface; it is not embedded into Desktop.
---
## Quick Start
### Server
@ -126,6 +145,8 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
- **Native services** — systemd on Linux, Windows Services on Windows
- **Full audit trail** — all operations logged with context, sanitized against log injection
- **Native local console** — live machine health and operations beside the Agent, with no browser or cloud dependency
- **Connected inspection** — follow a process into its service, container, socket, capability set, and owning package
---
@ -155,6 +176,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- Setup accepts an operator-supplied signing keypair — bring-your-own-key deployments
- Reversible token encryption with one-liner restore
- Real-time heartbeat and rapid polling
- Native Qt/QML Desktop source for health, performance, processes, network, storage, containers, services, software, updates, security, and history
**Not yet done:**
- No AUR, Snap, Flatpak, or Homebrew support
@ -165,6 +187,8 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- Helper-side rehashing of normal registry artifacts before mutation
- Network isolation for the privileged helper invocation
- Capability-helper execution for Docker, Winget, and Windows Update
- Native Windows Desktop release artifact and installer proof
- GPU telemetry, rich disk health, and vendor-specific power sensors
---

View file

@ -251,7 +251,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
return nil, fmt.Errorf("failed to initialize command handler: %w", err)
}
// Initialize desktop manager (spawns Tauri system tray + local UI)
// Initialize the native Desktop manager.
var desktopMgr *desktop.Manager
if opts.EnableDesktop {
desktopMgr = desktop.NewManager(
@ -371,12 +371,41 @@ func RunPollingLoop(loopCtx *LoopContext) error {
if ctx.DesktopManager != nil {
onDesktopHealth = ctx.DesktopManager.RecordHealth
}
dockerProvider := func() (*localapi.DockerResponse, error) {
scanner, err := orchestrator.NewDockerScanner()
if err != nil {
return &localapi.DockerResponse{Available: false, CollectedAt: time.Now().UTC()}, nil
}
defer scanner.Close()
if !scanner.IsAvailable() {
return &localapi.DockerResponse{Available: false, CollectedAt: time.Now().UTC()}, nil
}
containers, err := scanner.ScanContainers()
if err != nil {
return nil, err
}
response := &localapi.DockerResponse{
Available: true, Version: scanner.GetEngineVersion(), Containers: containers,
Stacks: scanner.ScanStacks(containers), Count: len(containers), CollectedAt: time.Now().UTC(),
}
for _, container := range containers {
if container.State == "running" {
response.Running++
}
if container.Health == "unhealthy" {
response.Unhealthy++
}
}
return response, nil
}
localAPIServer, err := localapi.Start(localapi.Options{
Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan,
ApproveUpdate: approveUpdate,
OnDesktopHealth: onDesktopHealth,
DockerProvider: dockerProvider,
EventsProvider: ctx.EventBuffer.ReadBufferedEvents,
})
if err != nil {
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
@ -385,7 +414,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
// Start desktop app (connects to the local API socket above).
// On Linux the tray is launched by XDG autostart via the user's desktop
// On Linux Desktop is launched by XDG autostart via the user's desktop
// environment — the agent service must not spawn a second copy.
if ctx.DesktopManager != nil && runtime.GOOS != "linux" {
go ctx.DesktopManager.Start(ctx.Ctx)
@ -1135,7 +1164,7 @@ func reportSystemInfo(apiClient *client.Client, cfg *config.Config, desktopMgr *
}
// Desktop component state (UPDATE-002/INSTALL-004): installed/running/
// version, sourced from the tray's own health reports since the Linux tray
// version, sourced from Desktop's own health reports since the Linux app
// is autostart-launched, not agent-spawned. Merges under
// agent.metadata["desktop"] — the backend for a fleet "components
// installed" indicator.

View file

@ -23,21 +23,21 @@ func InitLogger(l *event.TeeLogger) {
// MigrationState tracks migration completion status (used by migration package)
type MigrationState struct {
LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"`
LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"`
}
// ProxyConfig holds proxy configuration
type ProxyConfig struct {
Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional)
}
@ -45,14 +45,14 @@ type ProxyConfig struct {
// TLSConfig holds TLS/security configuration
type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
}
// NetworkConfig holds network-related configuration
type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout
Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
@ -60,11 +60,11 @@ type NetworkConfig struct {
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error)
Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
}
// SecurityLogConfig holds configuration for security logging
@ -103,19 +103,19 @@ type PollingConfig struct {
// ProcessExplorerConfig holds limits for process detail data collection.
// Zero values use the built-in defaults.
type ProcessExplorerConfig struct {
MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000)
MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500)
MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500)
MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000)
MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50)
MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200)
MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000)
MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500)
MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500)
MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000)
MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50)
MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200)
MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100)
}
// Config holds agent configuration
type Config struct {
// Version Information
Version string `json:"version,omitempty"` // Config schema version
Version string `json:"version,omitempty"` // Config schema version
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
// Server Configuration
@ -189,14 +189,14 @@ type Config struct {
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
}
// DesktopConfig controls the Tauri desktop app (system tray + local UI).
// DesktopConfig controls the native Qt/QML local-machine operations console.
// The agent service spawns the desktop binary as a child process when a
// desktop session is detected. The binary connects back to the agent's
// local API socket.
type DesktopConfig struct {
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
}
// Load reads configuration from multiple sources with priority order:
@ -242,7 +242,7 @@ type CLIFlags struct {
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
LogLevel string
ConfigFile string
Tags []string
Organization string
@ -279,16 +279,16 @@ func getDefaultConfig() *Config {
}
return &Config{
Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080",
Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes
// Server Authentication
RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration
RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration
// Agent Behavior
RapidPollingEnabled: false,
@ -783,15 +783,15 @@ func (c *Config) IsRegistered() bool {
type OSType string
const (
OSTypeLinux OSType = "linux"
OSTypeLinux OSType = "linux"
OSTypeWindows OSType = "windows"
OSTypeDarwin OSType = "darwin"
OSTypeDarwin OSType = "darwin"
)
// OS holds OS-specific information
type OS struct {
Type OSType `json:"type"`
Arch string `json:"arch,omitempty"`
Type OSType `json:"type"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
}

View file

@ -1,7 +1,7 @@
// Package desktop manages the Tauri desktop app (system tray + local UI shell).
// 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 provides a system tray icon with a local dashboard.
// and presents Agent-owned machine state and bounded operator intent.
package desktop
import (
@ -30,8 +30,8 @@ type Manager struct {
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
// 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 {
@ -40,8 +40,8 @@ type healthReport struct {
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.
// 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.
@ -201,7 +201,7 @@ func (m *Manager) Status() (running bool, pid int) {
return true, m.cmd.Process.Pid
}
// RecordHealth stores the tray's self-report (POST /v1/desktop via localapi).
// 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()

View file

@ -27,7 +27,7 @@ var (
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
)
// LocalApproveRequest is the tray's approval submission.
// LocalApproveRequest is RedFlag Desktop's approval submission.
type LocalApproveRequest struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
@ -37,7 +37,7 @@ type LocalApproveRequest struct {
}
// LocalApproveResult carries the gate verdicts plus the execution outcome so
// the tray can render exactly what was checked and what happened.
// Desktop can render exactly what was checked and what happened.
type LocalApproveResult struct {
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`

View file

@ -68,14 +68,14 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
desktopPath += ".exe"
}
check(fileExists(desktopPath),
"desktop binary not found at %s — system tray will not appear", desktopPath)
"desktop binary not found at %s — local operations console will not appear", desktopPath)
}
// --- Autostart entry (Linux only) ---
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
check(fileExists(autostartPath),
"desktop autostart entry not found at %s — tray will not start on next login", autostartPath)
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
}
// --- Socket directory permissions ---
@ -88,9 +88,9 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
if info, err := os.Stat(resolved); err == nil {
perm := info.Mode().Perm()
check(perm&0o010 != 0,
"localapi socket dir %s has permissions %#o — the tray user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
"localapi socket dir %s has permissions %#o — the Desktop user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
} else {
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — tray may not work: %v", resolved, err)
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — Desktop may not work: %v", resolved, err)
gaps++
}

View file

@ -19,7 +19,7 @@ type ClientOptions struct {
Timeout time.Duration
}
// Snapshot is the compact local state needed by status probes and tray shells.
// Snapshot is the compact local state needed by status probes and Desktop.
type Snapshot struct {
Identity IdentityResponse `json:"identity"`
Status StatusResponse `json:"status"`

View file

@ -10,11 +10,14 @@ import (
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
@ -42,16 +45,25 @@ var (
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
Config *config.Config
GroupName string
UnixSocketPath string
WindowsPipeName string
LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider
SystemProvider func() (*system.SystemInfo, error)
ProcessProvider func(limit int) ([]system.TopProcess, error)
Config *config.Config
GroupName string
UnixSocketPath string
WindowsPipeName string
LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider
SystemProvider func() (*system.SystemInfo, error)
ProcessProvider func(limit int) ([]system.TopProcess, error)
MonitorProvider func() (*system.ResourceSnapshot, error)
ProcessesProvider func() (*system.FullProcessSnapshot, error)
ProcessDetailProvider func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
SoftwareProvider func() (*system.SoftwareSnapshot, error)
PackageDetailProvider func(packageType, identity string) (*system.PackageDetail, error)
ConnectionsProvider func() (*system.ConnectionSnapshot, error)
ServicesProvider func() (*system.ServiceSnapshot, error)
DockerProvider func() (*DockerResponse, error)
EventsProvider func() ([]*models.SystemEvent, error)
// TriggerScan enqueues a package-update scan through the agent's existing
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
// group boundary on the socket/pipe — anyone who can connect may trigger.
@ -62,8 +74,8 @@ type Options struct {
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
// ErrApprovalUnavailable to control the HTTP status.
ApproveUpdate func(body []byte) (interface{}, error)
// OnDesktopHealth receives each tray self-report (POST /v1/desktop) so the
// agent can track tray liveness/version — on Linux the tray is autostart-
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
// agent can track app liveness/version — on Linux Desktop is autostart-
// launched and this is the only signal. Nil means reports are logged only.
OnDesktopHealth func(version string, windowOpen bool)
}
@ -74,6 +86,7 @@ type Server struct {
listener net.Listener
address string
logf func(format string, args ...interface{})
monitor *system.ResourceMonitor
}
// Start creates a platform-native local listener and serves the read-only local
@ -89,6 +102,12 @@ func Start(opts Options) (*Server, error) {
opts.RequestLog = log.Printf
}
var monitor *system.ResourceMonitor
if opts.MonitorProvider == nil {
monitor = system.NewResourceMonitor(time.Second, 300)
monitor.Start()
opts.MonitorProvider = monitor.Snapshot
}
handler := newHandler(opts)
httpServer := &http.Server{Handler: handler}
@ -98,6 +117,9 @@ func Start(opts Options) (*Server, error) {
if listener == nil {
listener, address, err = listen(opts)
if err != nil {
if monitor != nil {
monitor.Stop()
}
return nil, err
}
} else {
@ -109,6 +131,7 @@ func Start(opts Options) (*Server, error) {
listener: listener,
address: address,
logf: opts.RequestLog,
monitor: monitor,
}
go func() {
@ -129,6 +152,9 @@ func (s *Server) Stop() {
if err := s.httpServer.Close(); err != nil && s.logf != nil {
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
}
if s.monitor != nil {
s.monitor.Stop()
}
}
type handler struct {
@ -140,6 +166,15 @@ type handler struct {
onDesktopHealth func(version string, windowOpen bool)
systemInfo func() (*system.SystemInfo, error)
topProcesses func(limit int) ([]system.TopProcess, error)
monitorSnapshot func() (*system.ResourceSnapshot, error)
processes func() (*system.FullProcessSnapshot, error)
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
software func() (*system.SoftwareSnapshot, error)
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
connections func() (*system.ConnectionSnapshot, error)
services func() (*system.ServiceSnapshot, error)
docker func() (*DockerResponse, error)
events func() ([]*models.SystemEvent, error)
}
// DesktopStatusProvider allows the desktop manager to report its status.
@ -205,6 +240,38 @@ type SystemResponse struct {
CollectedAt time.Time `json:"collected_at"`
}
type DockerResponse struct {
Available bool `json:"available"`
Version string `json:"version,omitempty"`
Containers []client.DockerReportContainer `json:"containers"`
Stacks []client.DockerReportStack `json:"stacks"`
Count int `json:"count"`
Running int `json:"running"`
Unhealthy int `json:"unhealthy"`
CollectedAt time.Time `json:"collected_at"`
}
type EventsResponse struct {
Events []*models.SystemEvent `json:"events"`
Count int `json:"count"`
CollectedAt time.Time `json:"collected_at"`
}
type SecurityResponse struct {
CommandSigningEnabled bool `json:"command_signing_enabled"`
CommandEnforcement string `json:"command_enforcement"`
TLSVerification bool `json:"tls_verification"`
SecurityLogging bool `json:"security_logging"`
KernelEnforcement bool `json:"kernel_enforcement"`
KernelFailClosed bool `json:"kernel_fail_closed"`
DegradedMode bool `json:"degraded_mode"`
Registered bool `json:"registered"`
CriticalUpdates int `json:"critical_updates"`
HighUpdates int `json:"high_updates"`
Capabilities cache.CapabilityTokenState `json:"capabilities"`
CollectedAt time.Time `json:"collected_at"`
}
func newHandler(opts Options) http.Handler {
h := &handler{
cfg: opts.Config,
@ -215,6 +282,15 @@ func newHandler(opts Options) http.Handler {
onDesktopHealth: opts.OnDesktopHealth,
systemInfo: opts.SystemProvider,
topProcesses: opts.ProcessProvider,
monitorSnapshot: opts.MonitorProvider,
processes: opts.ProcessesProvider,
processDetail: opts.ProcessDetailProvider,
software: opts.SoftwareProvider,
packageDetail: opts.PackageDetailProvider,
connections: opts.ConnectionsProvider,
services: opts.ServicesProvider,
docker: opts.DockerProvider,
events: opts.EventsProvider,
}
if h.systemInfo == nil {
h.systemInfo = func() (*system.SystemInfo, error) {
@ -224,6 +300,24 @@ func newHandler(opts Options) http.Handler {
if h.topProcesses == nil {
h.topProcesses = system.GetTopProcesses
}
if h.processes == nil {
h.processes = system.GetFullProcessSnapshot
}
if h.processDetail == nil {
h.processDetail = system.GetProcessDetail
}
if h.software == nil {
h.software = system.GetSoftwareSnapshot
}
if h.packageDetail == nil {
h.packageDetail = system.GetPackageDetail
}
if h.connections == nil {
h.connections = system.GetConnectionsSnapshot
}
if h.services == nil {
h.services = system.GetServicesSnapshot
}
if h.loadCache == nil {
h.loadCache = cache.Load
}
@ -233,6 +327,16 @@ func newHandler(opts Options) http.Handler {
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/system", h.system)
mux.HandleFunc("/v1/monitor", h.monitor)
mux.HandleFunc("/v1/processes", h.processList)
mux.HandleFunc("/v1/processes/", h.processByPID)
mux.HandleFunc("/v1/software", h.softwareList)
mux.HandleFunc("/v1/software/detail", h.softwareDetail)
mux.HandleFunc("/v1/connections", h.connectionList)
mux.HandleFunc("/v1/services", h.serviceList)
mux.HandleFunc("/v1/containers", h.containerList)
mux.HandleFunc("/v1/events", h.eventList)
mux.HandleFunc("/v1/security", h.security)
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
@ -240,6 +344,192 @@ func newHandler(opts Options) http.Handler {
return mux
}
func (h *handler) containerList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.docker == nil {
writeJSON(w, DockerResponse{Available: false, CollectedAt: time.Now().UTC()})
return
}
snapshot, err := h.docker()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] container_inventory_failed error=%v", err)
http.Error(w, "container inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) eventList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.events == nil {
writeJSON(w, EventsResponse{CollectedAt: time.Now().UTC()})
return
}
events, err := h.events()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] event_history_failed error=%v", err)
http.Error(w, "local event history unavailable", http.StatusServiceUnavailable)
return
}
sort.Slice(events, func(i, j int) bool { return events[i].CreatedAt.After(events[j].CreatedAt) })
writeJSON(w, EventsResponse{Events: events, Count: len(events), CollectedAt: time.Now().UTC()})
}
func (h *handler) security(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
response := SecurityResponse{
CommandSigningEnabled: h.cfg.CommandSigning.Enabled,
CommandEnforcement: h.cfg.CommandSigning.EnforcementMode,
TLSVerification: !h.cfg.TLS.InsecureSkipVerify,
SecurityLogging: h.cfg.SecurityLogging.Enabled,
KernelEnforcement: h.cfg.KernelEnforcement.Enabled,
KernelFailClosed: h.cfg.KernelEnforcement.FailClosed,
DegradedMode: h.cfg.DegradedMode,
Registered: h.cfg.IsRegistered(),
CriticalUpdates: localCache.Summary.BySeverity["critical"],
HighUpdates: localCache.Summary.BySeverity["high"] + localCache.Summary.BySeverity["important"],
Capabilities: localCache.Capabilities,
CollectedAt: time.Now().UTC(),
}
writeJSON(w, response)
}
func (h *handler) connectionList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.connections()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] connection_inventory_failed error=%v", err)
http.Error(w, "connection inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) serviceList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.services()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] service_inventory_failed error=%v", err)
http.Error(w, "service inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) monitor(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.monitorSnapshot == nil {
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
return
}
snapshot, err := h.monitorSnapshot()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] resource_monitor_failed error=%v", err)
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) processList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.processes()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] process_inventory_failed error=%v", err)
http.Error(w, "process inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) processByPID(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
rawPID := strings.TrimPrefix(r.URL.Path, "/v1/processes/")
pid, err := strconv.Atoi(rawPID)
if err != nil || pid <= 0 || strings.Contains(rawPID, "/") {
http.Error(w, "invalid process id", http.StatusBadRequest)
return
}
caps := processCaps(h.cfg.ProcessExplorer)
process, err := h.processDetail(pid, caps)
if err != nil {
log.Printf("[INFO] [agent] [localapi] process_detail_unavailable pid=%d error=%v", pid, err)
http.Error(w, "process unavailable", http.StatusNotFound)
return
}
writeJSON(w, process)
}
func (h *handler) softwareList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.software()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] software_inventory_failed error=%v", err)
http.Error(w, "software inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) softwareDetail(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
packageType := strings.TrimSpace(r.URL.Query().Get("manager"))
identity := strings.TrimSpace(r.URL.Query().Get("identity"))
if packageType == "" || identity == "" {
http.Error(w, "manager and identity are required", http.StatusBadRequest)
return
}
detail, err := h.packageDetail(packageType, identity)
if err != nil {
log.Printf("[INFO] [agent] [localapi] software_detail_unavailable manager=%s identity=%s error=%v", packageType, identity, err)
http.Error(w, "software detail unavailable", http.StatusNotFound)
return
}
writeJSON(w, detail)
}
func processCaps(cfg config.ProcessExplorerConfig) system.ProcessCaps {
capOr := func(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
return system.ProcessCaps{
MaxOpenFiles: capOr(cfg.MaxOpenFiles, 2000),
MaxSockets: capOr(cfg.MaxSockets, 500),
MaxPipes: capOr(cfg.MaxPipes, 500),
MaxMemoryMap: capOr(cfg.MaxMemoryMap, 2000),
MaxNamespaces: capOr(cfg.MaxNamespaces, 50),
MaxEnvKeys: capOr(cfg.MaxEnvKeys, 200),
MaxListeningPorts: capOr(cfg.MaxListeningPorts, 100),
}
}
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return

View file

@ -158,6 +158,113 @@ func TestSystemHealthUsesAgentCollectors(t *testing.T) {
}
}
func TestMonitorAndProcessInventoryUseAgentCollectors(t *testing.T) {
now := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
handler := newHandler(Options{
Config: testConfig(t),
LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
},
MonitorProvider: func() (*system.ResourceSnapshot, error) {
return &system.ResourceSnapshot{
CollectedAt: now,
CPU: system.CPUMetrics{UsagePercent: 27.5, Load1: 1.2},
Memory: system.MemoryMetrics{UsedPercent: 42},
History: []system.ResourcePoint{{Timestamp: now, CPUPercent: 27.5}},
}, nil
},
ProcessesProvider: func() (*system.FullProcessSnapshot, error) {
return &system.FullProcessSnapshot{
Processes: []system.FullProcess{{PID: 42, Name: "redflag-agent", User: "root"}},
ProcessCount: 1,
ScannedAt: now,
}, nil
},
ProcessDetailProvider: func(pid int, caps system.ProcessCaps) (*system.FullProcess, error) {
if pid != 42 {
t.Fatalf("pid = %d, want 42", pid)
}
if caps.MaxSockets != 500 || caps.MaxOpenFiles != 2000 {
t.Fatalf("default process caps = %#v", caps)
}
return &system.FullProcess{PID: pid, Name: "redflag-agent", Path: "/usr/bin/redflag-agent"}, nil
},
SoftwareProvider: func() (*system.SoftwareSnapshot, error) {
return &system.SoftwareSnapshot{
Supported: true,
Packages: []system.InstalledPackage{{PackageType: "pacman", Identity: "redflag-agent", Name: "redflag-agent", Version: "0.3.0", InstallReason: "explicit", Origin: "repository"}},
Count: 1,
}, nil
},
PackageDetailProvider: func(packageType, identity string) (*system.PackageDetail, error) {
if packageType != "pacman" || identity != "redflag-agent" {
t.Fatalf("package detail request = %s/%s", packageType, identity)
}
return &system.PackageDetail{InstalledPackage: system.InstalledPackage{PackageType: packageType, Identity: identity, Name: identity, Version: "0.3.0"}, DependsOn: []string{"glibc"}}, nil
},
ConnectionsProvider: func() (*system.ConnectionSnapshot, error) {
return &system.ConnectionSnapshot{Connections: []system.Connection{{PID: 42, Process: "redflag-agent", Protocol: "TCP", LocalPort: 443, State: "LISTEN"}}, Count: 1, CollectedAt: now}, nil
},
ServicesProvider: func() (*system.ServiceSnapshot, error) {
return &system.ServiceSnapshot{Manager: "systemd", Services: []system.Service{{Name: "redflag-agent", ActiveState: "active", SubState: "running"}}, Count: 1, Running: 1, CollectedAt: now}, nil
},
})
var monitor system.ResourceSnapshot
requestJSON(t, handler, http.MethodGet, "/v1/monitor", &monitor)
if monitor.CPU.UsagePercent != 27.5 || len(monitor.History) != 1 {
t.Fatalf("monitor = %#v", monitor)
}
var processes system.FullProcessSnapshot
requestJSON(t, handler, http.MethodGet, "/v1/processes", &processes)
if processes.ProcessCount != 1 || processes.Processes[0].Name != "redflag-agent" {
t.Fatalf("processes = %#v", processes)
}
var process system.FullProcess
requestJSON(t, handler, http.MethodGet, "/v1/processes/42", &process)
if process.Path != "/usr/bin/redflag-agent" {
t.Fatalf("process detail = %#v", process)
}
var software system.SoftwareSnapshot
requestJSON(t, handler, http.MethodGet, "/v1/software", &software)
if software.Count != 1 || software.Packages[0].Version != "0.3.0" {
t.Fatalf("software = %#v", software)
}
var packageDetail system.PackageDetail
requestJSON(t, handler, http.MethodGet, "/v1/software/detail?manager=pacman&identity=redflag-agent", &packageDetail)
if packageDetail.Name != "redflag-agent" || len(packageDetail.DependsOn) != 1 {
t.Fatalf("package detail = %#v", packageDetail)
}
var connections system.ConnectionSnapshot
requestJSON(t, handler, http.MethodGet, "/v1/connections", &connections)
if connections.Count != 1 || connections.Connections[0].State != "LISTEN" {
t.Fatalf("connections = %#v", connections)
}
var services system.ServiceSnapshot
requestJSON(t, handler, http.MethodGet, "/v1/services", &services)
if services.Running != 1 || services.Services[0].Name != "redflag-agent" {
t.Fatalf("services = %#v", services)
}
}
func TestProcessDetailRejectsInvalidPID(t *testing.T) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}})
req := httptest.NewRequest(http.MethodGet, "/v1/processes/not-a-pid", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return nil, errors.New("cannot read cache")

View file

@ -252,7 +252,7 @@ func signalDesktopRestart(target string) error {
return nil
}
// pkill exit 1 = no process matched; taskkill exit 128 = process not found.
// Both are non-errors — the tray wasn't running.
// Both are non-errors — Desktop wasn't running.
if exitErr, ok := err.(*exec.ExitError); ok {
if (runtime.GOOS == "linux" && exitErr.ExitCode() == 1) ||
(runtime.GOOS == "windows" && exitErr.ExitCode() == 128) {
@ -301,4 +301,3 @@ func computeFileSHA256Hex(path string) (string, error) {
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View file

@ -0,0 +1,26 @@
package system
import "time"
type ConnectionSnapshot struct {
Connections []Connection `json:"connections"`
Count int `json:"count"`
CollectedAt time.Time `json:"collected_at"`
}
type Connection struct {
PID int `json:"pid,omitempty"`
Process string `json:"process,omitempty"`
Protocol string `json:"protocol"`
Family string `json:"family"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
RemoteAddr string `json:"remote_addr,omitempty"`
RemotePort int `json:"remote_port,omitempty"`
State string `json:"state"`
Inode uint64 `json:"inode"`
}
func GetConnectionsSnapshot() (*ConnectionSnapshot, error) {
return getConnectionsSnapshot()
}

View file

@ -0,0 +1,104 @@
//go:build linux
package system
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
owners := socketOwners()
var connections []Connection
for _, source := range []struct {
path, protocol, family string
}{
{"/proc/net/tcp", "TCP", "IPv4"},
{"/proc/net/tcp6", "TCP", "IPv6"},
{"/proc/net/udp", "UDP", "IPv4"},
{"/proc/net/udp6", "UDP", "IPv6"},
} {
data, err := os.ReadFile(source.path)
if err != nil {
continue
}
for index, line := range strings.Split(string(data), "\n") {
if index == 0 || strings.TrimSpace(line) == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
localAddr, localPort := parseHexAddr(fields[1])
remoteAddr, remotePort := parseHexAddr(fields[2])
inode := atouint64(fields[9])
owner := owners[inode]
state := fields[3]
if source.protocol == "TCP" {
state = tcpState(state)
}
connections = append(connections, Connection{
PID: owner.pid, Process: owner.name, Protocol: source.protocol, Family: source.family,
LocalAddr: localAddr, LocalPort: localPort, RemoteAddr: remoteAddr,
RemotePort: remotePort, State: state, Inode: inode,
})
}
}
if len(connections) == 0 {
if _, err := os.Stat("/proc/net/tcp"); err != nil {
return nil, fmt.Errorf("read network connection table: %w", err)
}
}
sort.Slice(connections, func(i, j int) bool {
if connections[i].State != connections[j].State {
return connections[i].State == "LISTEN"
}
if connections[i].Process != connections[j].Process {
return connections[i].Process < connections[j].Process
}
return connections[i].LocalPort < connections[j].LocalPort
})
return &ConnectionSnapshot{Connections: connections, Count: len(connections), CollectedAt: time.Now().UTC()}, nil
}
type socketOwner struct {
pid int
name string
}
func socketOwners() map[uint64]socketOwner {
owners := make(map[uint64]socketOwner)
entries, err := os.ReadDir("/proc")
if err != nil {
return owners
}
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil || !entry.IsDir() {
continue
}
nameBytes, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
name := strings.TrimSpace(string(nameBytes))
fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid))
if err != nil {
continue
}
for _, fd := range fds {
target, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fd.Name()))
if err != nil || !strings.HasPrefix(target, "socket:[") {
continue
}
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
value, err := strconv.ParseUint(inode, 10, 64)
if err == nil {
owners[value] = socketOwner{pid: pid, name: name}
}
}
}
return owners
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package system
import "fmt"
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
return nil, fmt.Errorf("connection inventory not supported on this platform")
}

View file

@ -0,0 +1,297 @@
package system
import (
"errors"
"sync"
"time"
)
var ErrMonitorUnavailable = errors.New("system resource monitor unavailable")
// ResourceMonitor owns the bounded, live machine history consumed by Desktop.
// Collection stays in the Agent: UIs receive observations and express intent;
// they never learn a second way to inspect or mutate the machine.
type ResourceMonitor struct {
mu sync.RWMutex
interval time.Duration
capacity int
stop chan struct{}
done chan struct{}
started bool
previous *rawMonitorSample
latest *ResourceSnapshot
lastErr error
history []ResourcePoint
}
type ResourceSnapshot struct {
CollectedAt time.Time `json:"collected_at"`
IntervalMS int64 `json:"interval_ms"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Network NetworkMetrics `json:"network"`
Storage StorageMetrics `json:"storage"`
Thermals []ThermalReading `json:"thermals,omitempty"`
History []ResourcePoint `json:"history"`
}
type CPUMetrics struct {
UsagePercent float64 `json:"usage_percent"`
UserPercent float64 `json:"user_percent"`
SystemPercent float64 `json:"system_percent"`
IOWaitPercent float64 `json:"iowait_percent"`
Load1 float64 `json:"load_1"`
Load5 float64 `json:"load_5"`
Load15 float64 `json:"load_15"`
PerCore []CoreUsage `json:"per_core"`
}
type CoreUsage struct {
Core int `json:"core"`
UsagePercent float64 `json:"usage_percent"`
}
type MemoryMetrics struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
AvailableBytes uint64 `json:"available_bytes"`
UsedPercent float64 `json:"used_percent"`
SwapTotalBytes uint64 `json:"swap_total_bytes"`
SwapUsedBytes uint64 `json:"swap_used_bytes"`
SwapPercent float64 `json:"swap_percent"`
}
type NetworkMetrics struct {
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
ReceiveBytes uint64 `json:"receive_bytes"`
TransmitBytes uint64 `json:"transmit_bytes"`
Interfaces []NetworkInterface `json:"interfaces"`
}
type NetworkInterface struct {
Name string `json:"name"`
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
ReceiveBytes uint64 `json:"receive_bytes"`
TransmitBytes uint64 `json:"transmit_bytes"`
}
type StorageMetrics struct {
ReadBytesPerSecond float64 `json:"read_bytes_per_second"`
WriteBytesPerSecond float64 `json:"write_bytes_per_second"`
ReadBytes uint64 `json:"read_bytes"`
WriteBytes uint64 `json:"write_bytes"`
}
type ThermalReading struct {
Name string `json:"name"`
Celsius float64 `json:"celsius"`
}
type ResourcePoint struct {
Timestamp time.Time `json:"timestamp"`
CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"`
NetworkReceivePerSec float64 `json:"network_receive_per_second"`
NetworkTransmitPerSec float64 `json:"network_transmit_per_second"`
DiskReadPerSec float64 `json:"disk_read_per_second"`
DiskWritePerSec float64 `json:"disk_write_per_second"`
}
type cpuCounters struct {
User, Nice, System, Idle, IOWait, IRQ, SoftIRQ, Steal uint64
}
func (c cpuCounters) total() uint64 {
return c.User + c.Nice + c.System + c.Idle + c.IOWait + c.IRQ + c.SoftIRQ + c.Steal
}
func (c cpuCounters) idle() uint64 { return c.Idle + c.IOWait }
type byteCounters struct{ Receive, Transmit uint64 }
type rawMonitorSample struct {
at time.Time
cpu cpuCounters
cores []cpuCounters
load [3]float64
memory MemoryMetrics
network map[string]byteCounters
diskRead uint64
diskWrite uint64
thermals []ThermalReading
}
func NewResourceMonitor(interval time.Duration, capacity int) *ResourceMonitor {
if interval <= 0 {
interval = time.Second
}
if capacity <= 0 {
capacity = 300
}
return &ResourceMonitor{
interval: interval,
capacity: capacity,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
func (m *ResourceMonitor) Start() {
m.mu.Lock()
if m.started {
m.mu.Unlock()
return
}
m.started = true
m.mu.Unlock()
go func() {
defer close(m.done)
m.collect()
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.collect()
case <-m.stop:
return
}
}
}()
}
func (m *ResourceMonitor) Stop() {
m.mu.Lock()
if !m.started {
m.mu.Unlock()
return
}
m.started = false
close(m.stop)
m.mu.Unlock()
<-m.done
}
func (m *ResourceMonitor) Snapshot() (*ResourceSnapshot, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.latest == nil {
if m.lastErr != nil {
return nil, m.lastErr
}
return nil, ErrMonitorUnavailable
}
copy := *m.latest
copy.CPU.PerCore = append([]CoreUsage(nil), m.latest.CPU.PerCore...)
copy.Network.Interfaces = append([]NetworkInterface(nil), m.latest.Network.Interfaces...)
copy.Thermals = append([]ThermalReading(nil), m.latest.Thermals...)
copy.History = append([]ResourcePoint(nil), m.history...)
return &copy, nil
}
func (m *ResourceMonitor) collect() {
raw, err := readRawMonitorSample()
m.mu.Lock()
defer m.mu.Unlock()
if err != nil {
m.lastErr = err
return
}
snapshot := snapshotFromRaw(raw, m.previous, m.interval)
m.previous = raw
m.lastErr = nil
point := ResourcePoint{
Timestamp: snapshot.CollectedAt,
CPUPercent: snapshot.CPU.UsagePercent,
MemoryPercent: snapshot.Memory.UsedPercent,
NetworkReceivePerSec: snapshot.Network.ReceiveBytesPerSecond,
NetworkTransmitPerSec: snapshot.Network.TransmitBytesPerSecond,
DiskReadPerSec: snapshot.Storage.ReadBytesPerSecond,
DiskWritePerSec: snapshot.Storage.WriteBytesPerSecond,
}
m.history = append(m.history, point)
if len(m.history) > m.capacity {
copy(m.history, m.history[len(m.history)-m.capacity:])
m.history = m.history[:m.capacity]
}
snapshot.History = append([]ResourcePoint(nil), m.history...)
m.latest = snapshot
}
func snapshotFromRaw(now, previous *rawMonitorSample, fallback time.Duration) *ResourceSnapshot {
elapsed := fallback.Seconds()
if previous != nil {
elapsed = now.at.Sub(previous.at).Seconds()
}
if elapsed <= 0 {
elapsed = 1
}
cpu := CPUMetrics{Load1: now.load[0], Load5: now.load[1], Load15: now.load[2]}
if previous != nil {
cpu.UsagePercent, cpu.UserPercent, cpu.SystemPercent, cpu.IOWaitPercent = cpuDelta(now.cpu, previous.cpu)
for index, counters := range now.cores {
usage := 0.0
if index < len(previous.cores) {
usage, _, _, _ = cpuDelta(counters, previous.cores[index])
}
cpu.PerCore = append(cpu.PerCore, CoreUsage{Core: index, UsagePercent: usage})
}
}
network := NetworkMetrics{}
for name, counters := range now.network {
iface := NetworkInterface{Name: name, ReceiveBytes: counters.Receive, TransmitBytes: counters.Transmit}
if previous != nil {
before := previous.network[name]
iface.ReceiveBytesPerSecond = perSecond(counters.Receive, before.Receive, elapsed)
iface.TransmitBytesPerSecond = perSecond(counters.Transmit, before.Transmit, elapsed)
}
network.Interfaces = append(network.Interfaces, iface)
if name != "lo" {
network.ReceiveBytes += counters.Receive
network.TransmitBytes += counters.Transmit
network.ReceiveBytesPerSecond += iface.ReceiveBytesPerSecond
network.TransmitBytesPerSecond += iface.TransmitBytesPerSecond
}
}
storage := StorageMetrics{ReadBytes: now.diskRead, WriteBytes: now.diskWrite}
if previous != nil {
storage.ReadBytesPerSecond = perSecond(now.diskRead, previous.diskRead, elapsed)
storage.WriteBytesPerSecond = perSecond(now.diskWrite, previous.diskWrite, elapsed)
}
return &ResourceSnapshot{
CollectedAt: now.at,
IntervalMS: int64(elapsed * 1000),
CPU: cpu,
Memory: now.memory,
Network: network,
Storage: storage,
Thermals: now.thermals,
}
}
func cpuDelta(now, before cpuCounters) (usage, user, system, iowait float64) {
total := now.total() - before.total()
if total == 0 {
return 0, 0, 0, 0
}
percent := func(delta uint64) float64 { return float64(delta) * 100 / float64(total) }
return percent(total - (now.idle() - before.idle())),
percent((now.User + now.Nice) - (before.User + before.Nice)),
percent((now.System + now.IRQ + now.SoftIRQ) - (before.System + before.IRQ + before.SoftIRQ)),
percent(now.IOWait - before.IOWait)
}
func perSecond(now, before uint64, elapsed float64) float64 {
if now < before || elapsed <= 0 {
return 0
}
return float64(now-before) / elapsed
}

View file

@ -0,0 +1,205 @@
//go:build linux
package system
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func readRawMonitorSample() (*rawMonitorSample, error) {
cpu, cores, err := readCPUCounters("/proc/stat")
if err != nil {
return nil, err
}
memory, err := readMemoryMetrics("/proc/meminfo")
if err != nil {
return nil, err
}
load, _ := readLoadAverage("/proc/loadavg")
network, _ := readNetworkCounters("/proc/net/dev")
diskRead, diskWrite := readDiskCounters()
return &rawMonitorSample{
at: time.Now().UTC(),
cpu: cpu,
cores: cores,
load: load,
memory: memory,
network: network,
diskRead: diskRead,
diskWrite: diskWrite,
thermals: readThermals(),
}, nil
}
func readCPUCounters(path string) (cpuCounters, []cpuCounters, error) {
file, err := os.Open(path)
if err != nil {
return cpuCounters{}, nil, fmt.Errorf("read cpu counters: %w", err)
}
defer file.Close()
var total cpuCounters
var cores []cpuCounters
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
if len(cores) > 0 {
break
}
continue
}
counters := parseCPUFields(fields[1:])
if fields[0] == "cpu" {
total = counters
} else {
cores = append(cores, counters)
}
}
if err := scanner.Err(); err != nil {
return cpuCounters{}, nil, err
}
if total.total() == 0 {
return cpuCounters{}, nil, fmt.Errorf("cpu counters are empty")
}
return total, cores, nil
}
func parseCPUFields(fields []string) cpuCounters {
values := make([]uint64, 8)
for i := 0; i < len(values) && i < len(fields); i++ {
values[i], _ = strconv.ParseUint(fields[i], 10, 64)
}
return cpuCounters{User: values[0], Nice: values[1], System: values[2], Idle: values[3], IOWait: values[4], IRQ: values[5], SoftIRQ: values[6], Steal: values[7]}
}
func readMemoryMetrics(path string) (MemoryMetrics, error) {
data, err := os.ReadFile(path)
if err != nil {
return MemoryMetrics{}, fmt.Errorf("read memory counters: %w", err)
}
values := map[string]uint64{}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
key := strings.TrimSuffix(fields[0], ":")
value, _ := strconv.ParseUint(fields[1], 10, 64)
values[key] = value * 1024
}
}
total := values["MemTotal"]
available := values["MemAvailable"]
if total == 0 {
return MemoryMetrics{}, fmt.Errorf("MemTotal is unavailable")
}
used := total - available
swapTotal := values["SwapTotal"]
swapUsed := swapTotal - values["SwapFree"]
metrics := MemoryMetrics{TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsedPercent: float64(used) * 100 / float64(total), SwapTotalBytes: swapTotal, SwapUsedBytes: swapUsed}
if swapTotal > 0 {
metrics.SwapPercent = float64(swapUsed) * 100 / float64(swapTotal)
}
return metrics, nil
}
func readLoadAverage(path string) ([3]float64, error) {
var load [3]float64
data, err := os.ReadFile(path)
if err != nil {
return load, err
}
fields := strings.Fields(string(data))
for i := 0; i < 3 && i < len(fields); i++ {
load[i], _ = strconv.ParseFloat(fields[i], 64)
}
return load, nil
}
func readNetworkCounters(path string) (map[string]byteCounters, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
result := make(map[string]byteCounters)
for _, line := range strings.Split(string(data), "\n") {
name, values, ok := strings.Cut(line, ":")
if !ok {
continue
}
fields := strings.Fields(values)
if len(fields) < 9 {
continue
}
receive, _ := strconv.ParseUint(fields[0], 10, 64)
transmit, _ := strconv.ParseUint(fields[8], 10, 64)
result[strings.TrimSpace(name)] = byteCounters{Receive: receive, Transmit: transmit}
}
return result, nil
}
func readDiskCounters() (uint64, uint64) {
devices, err := os.ReadDir("/sys/block")
if err != nil {
return 0, 0
}
whole := make(map[string]struct{}, len(devices))
for _, device := range devices {
name := device.Name()
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") {
continue
}
whole[name] = struct{}{}
}
data, err := os.ReadFile("/proc/diskstats")
if err != nil {
return 0, 0
}
var read, written uint64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
if _, ok := whole[fields[2]]; !ok {
continue
}
sectorsRead, _ := strconv.ParseUint(fields[5], 10, 64)
sectorsWritten, _ := strconv.ParseUint(fields[9], 10, 64)
read += sectorsRead * 512
written += sectorsWritten * 512
}
return read, written
}
func readThermals() []ThermalReading {
paths, _ := filepath.Glob("/sys/class/thermal/thermal_zone*/temp")
var readings []ThermalReading
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
value, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
if err != nil {
continue
}
name := filepath.Base(filepath.Dir(path))
if data, err := os.ReadFile(filepath.Join(filepath.Dir(path), "type")); err == nil {
name = strings.TrimSpace(string(data))
}
if value > 1000 {
value /= 1000
}
if value > -100 && value < 250 {
readings = append(readings, ThermalReading{Name: name, Celsius: value})
}
}
sort.Slice(readings, func(i, j int) bool { return readings[i].Name < readings[j].Name })
return readings
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package system
import "fmt"
func readRawMonitorSample() (*rawMonitorSample, error) {
return nil, fmt.Errorf("%w on this platform", ErrMonitorUnavailable)
}

View file

@ -0,0 +1,68 @@
package system
import (
"math"
"testing"
"time"
)
func TestSnapshotFromRawComputesRatesAndHistoryPointInputs(t *testing.T) {
before := &rawMonitorSample{
at: time.Unix(100, 0),
cpu: cpuCounters{User: 100, System: 50, Idle: 850},
cores: []cpuCounters{{User: 100, Idle: 900}},
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, UsedPercent: 40},
network: map[string]byteCounters{"eth0": {Receive: 1000, Transmit: 2000}},
diskRead: 5000,
diskWrite: 8000,
}
now := &rawMonitorSample{
at: time.Unix(102, 0),
cpu: cpuCounters{User: 180, System: 70, Idle: 950},
cores: []cpuCounters{{User: 160, Idle: 1040}},
load: [3]float64{1.2, 0.8, 0.5},
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 450, AvailableBytes: 550, UsedPercent: 45},
network: map[string]byteCounters{"eth0": {Receive: 3000, Transmit: 5000}},
diskRead: 9000,
diskWrite: 14000,
}
snapshot := snapshotFromRaw(now, before, time.Second)
assertNear(t, snapshot.CPU.UsagePercent, 50)
assertNear(t, snapshot.CPU.UserPercent, 40)
assertNear(t, snapshot.CPU.SystemPercent, 10)
assertNear(t, snapshot.Network.ReceiveBytesPerSecond, 1000)
assertNear(t, snapshot.Network.TransmitBytesPerSecond, 1500)
assertNear(t, snapshot.Storage.ReadBytesPerSecond, 2000)
assertNear(t, snapshot.Storage.WriteBytesPerSecond, 3000)
if len(snapshot.CPU.PerCore) != 1 {
t.Fatalf("per-core samples = %d, want 1", len(snapshot.CPU.PerCore))
}
}
func TestResourceMonitorHistoryIsBounded(t *testing.T) {
monitor := NewResourceMonitor(time.Second, 2)
monitor.history = []ResourcePoint{{CPUPercent: 1}, {CPUPercent: 2}}
monitor.capacity = 2
monitor.latest = &ResourceSnapshot{}
monitor.previous = nil
monitor.mu.Lock()
monitor.history = append(monitor.history, ResourcePoint{CPUPercent: 3})
if len(monitor.history) > monitor.capacity {
copy(monitor.history, monitor.history[len(monitor.history)-monitor.capacity:])
monitor.history = monitor.history[:monitor.capacity]
}
monitor.mu.Unlock()
if len(monitor.history) != 2 || monitor.history[0].CPUPercent != 2 || monitor.history[1].CPUPercent != 3 {
t.Fatalf("bounded history = %#v", monitor.history)
}
}
func assertNear(t *testing.T, got, want float64) {
t.Helper()
if math.Abs(got-want) > 0.001 {
t.Fatalf("got %.3f, want %.3f", got, want)
}
}

View file

@ -3,7 +3,6 @@
package system
import (
"bytes"
"fmt"
@ -299,6 +298,10 @@ func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
if err != nil {
return nil, err
}
if owner, err := FindSoftwareOwner(proc.Path); err == nil {
proc.PackageManager = owner.PackageType
proc.PackageName = owner.PackageName
}
pidStr := strconv.Itoa(pid)
@ -395,9 +398,9 @@ func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
// TCP sockets
for _, proto := range []struct {
file string
family string
prot string
file string
family string
prot string
}{
{fmt.Sprintf("/proc/%s/net/tcp", pidStr), "IPv4", "TCP"},
{fmt.Sprintf("/proc/%s/net/tcp6", pidStr), "IPv6", "TCP"},
@ -474,7 +477,6 @@ func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
return sockets
}
// readEnvKeys reads /proc/[pid]/environ and returns only the key names.
// Values are never transmitted (security: env vars may contain secrets).
func readEnvKeys(pidStr string, maxKeys int) []string {

View file

@ -11,6 +11,8 @@ type ProcessOwner struct {
Unit string `json:"unit,omitempty"`
ContainerID string `json:"container_id,omitempty"`
ContainerRuntime string `json:"container_runtime,omitempty"`
PackageManager string `json:"package_manager,omitempty"`
PackageName string `json:"package_name,omitempty"`
}
// parseCgroupOwner reads the contents of /proc/[pid]/cgroup.

View file

@ -0,0 +1,22 @@
package system
import "time"
type ServiceSnapshot struct {
Manager string `json:"manager"`
Services []Service `json:"services"`
Count int `json:"count"`
Running int `json:"running"`
Failed int `json:"failed"`
CollectedAt time.Time `json:"collected_at"`
}
type Service struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
LoadState string `json:"load_state,omitempty"`
ActiveState string `json:"active_state"`
SubState string `json:"sub_state,omitempty"`
}
func GetServicesSnapshot() (*ServiceSnapshot, error) { return getServicesSnapshot() }

View file

@ -0,0 +1,51 @@
//go:build linux
package system
import (
"encoding/json"
"fmt"
"os/exec"
"sort"
"strings"
"time"
)
type systemdUnit struct {
Unit string `json:"unit"`
Load string `json:"load"`
Active string `json:"active"`
Sub string `json:"sub"`
Description string `json:"description"`
}
func getServicesSnapshot() (*ServiceSnapshot, error) {
command := exec.Command("systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager")
output, err := command.Output()
if err != nil {
return nil, fmt.Errorf("list systemd services: %w", err)
}
var units []systemdUnit
if err := json.Unmarshal(output, &units); err != nil {
return nil, fmt.Errorf("decode systemd service list: %w", err)
}
snapshot := &ServiceSnapshot{Manager: "systemd", CollectedAt: time.Now().UTC()}
for _, unit := range units {
name := strings.TrimSuffix(unit.Unit, ".service")
snapshot.Services = append(snapshot.Services, Service{Name: name, Description: unit.Description, LoadState: unit.Load, ActiveState: unit.Active, SubState: unit.Sub})
if unit.Active == "active" {
snapshot.Running++
}
if unit.Active == "failed" {
snapshot.Failed++
}
}
sort.Slice(snapshot.Services, func(i, j int) bool {
if snapshot.Services[i].ActiveState != snapshot.Services[j].ActiveState {
return snapshot.Services[i].ActiveState == "failed"
}
return snapshot.Services[i].Name < snapshot.Services[j].Name
})
snapshot.Count = len(snapshot.Services)
return snapshot, nil
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package system
import "fmt"
func getServicesSnapshot() (*ServiceSnapshot, error) {
return nil, fmt.Errorf("service inventory not supported on this platform")
}

View file

@ -0,0 +1,80 @@
package system
import "time"
const MaxPackageFiles = 5000
// SoftwareSnapshot is the Agent-owned view of installed software. Managers
// are reported independently so one broken inventory source does not turn
// another manager's known state into absence.
type SoftwareSnapshot struct {
Supported bool `json:"supported"`
Managers []SoftwareManager `json:"managers"`
Packages []InstalledPackage `json:"packages"`
Count int `json:"count"`
ExplicitCount int `json:"explicit_count"`
DependencyCount int `json:"dependency_count"`
ForeignCount int `json:"foreign_count"`
CollectedAt time.Time `json:"collected_at"`
}
// SoftwareManager records the state of one inventory source. Available and
// Error stay separate: installed-but-failing is not the same as absent.
type SoftwareManager struct {
Name string `json:"name"`
Available bool `json:"available"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
// InstalledPackage is the common local-machine package identity. Identity is
// the exact manager-owned query key; Name is the human/cross-domain package
// name used to join pending updates where that mapping is unambiguous.
type InstalledPackage struct {
PackageType string `json:"package_type"`
Identity string `json:"identity"`
Name string `json:"name"`
Version string `json:"version"`
Architecture string `json:"architecture,omitempty"`
Description string `json:"description,omitempty"`
Repository string `json:"repository,omitempty"`
InstallReason string `json:"install_reason"` // explicit, dependency, unknown
Origin string `json:"origin"` // repository, foreign, unknown
InstalledSizeBytes uint64 `json:"installed_size_bytes,omitempty"`
InstalledAt string `json:"installed_at,omitempty"`
}
// PackageDetail adds provenance and relationship data only when an operator
// drills into one package. Files are bounded; FileCount preserves the real
// total when the returned list is truncated.
type PackageDetail struct {
InstalledPackage
URL string `json:"url,omitempty"`
Licenses []string `json:"licenses,omitempty"`
Groups []string `json:"groups,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
OptionalDependencies []string `json:"optional_dependencies,omitempty"`
RequiredBy []string `json:"required_by,omitempty"`
Provides []string `json:"provides,omitempty"`
ConflictsWith []string `json:"conflicts_with,omitempty"`
Replaces []string `json:"replaces,omitempty"`
Packager string `json:"packager,omitempty"`
BuildDate string `json:"build_date,omitempty"`
Files []string `json:"files,omitempty"`
FileCount int `json:"file_count"`
FilesTruncated bool `json:"files_truncated"`
}
// SoftwareOwner is the package identity that owns an executable path.
type SoftwareOwner struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
}
func GetSoftwareSnapshot() (*SoftwareSnapshot, error) { return getSoftwareSnapshot() }
func GetPackageDetail(packageType, identity string) (*PackageDetail, error) {
return getPackageDetail(packageType, identity)
}
func FindSoftwareOwner(path string) (*SoftwareOwner, error) { return findSoftwareOwner(path) }

View file

@ -0,0 +1,512 @@
//go:build linux
package system
import (
"errors"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
var errSoftwareManagerUnavailable = errors.New("software manager unavailable")
type softwareCollector struct {
name string
binary string
inventory func() ([]InstalledPackage, error)
detail func(string) (*PackageDetail, error)
owner func(string) (*SoftwareOwner, error)
}
func linuxSoftwareCollectors() []softwareCollector {
return []softwareCollector{
{name: "pacman", binary: "pacman", inventory: pacmanInventory, detail: pacmanDetail, owner: pacmanOwner},
{name: "apt", binary: "dpkg-query", inventory: dpkgInventory, detail: dpkgDetail, owner: dpkgOwner},
{name: "dnf", binary: "rpm", inventory: rpmInventory, detail: rpmDetail, owner: rpmOwner},
}
}
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
snapshot := &SoftwareSnapshot{Supported: true, CollectedAt: time.Now().UTC()}
for _, collector := range linuxSoftwareCollectors() {
state := SoftwareManager{Name: collector.name}
if _, err := exec.LookPath(collector.binary); err != nil {
snapshot.Managers = append(snapshot.Managers, state)
continue
}
state.Available = true
packages, err := collector.inventory()
if err != nil {
state.Error = conciseCommandError(err)
snapshot.Managers = append(snapshot.Managers, state)
continue
}
state.Count = len(packages)
snapshot.Managers = append(snapshot.Managers, state)
snapshot.Packages = append(snapshot.Packages, packages...)
}
sort.Slice(snapshot.Packages, func(i, j int) bool {
if snapshot.Packages[i].Name != snapshot.Packages[j].Name {
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
}
return snapshot.Packages[i].PackageType < snapshot.Packages[j].PackageType
})
snapshot.Count = len(snapshot.Packages)
for _, pkg := range snapshot.Packages {
switch pkg.InstallReason {
case "explicit":
snapshot.ExplicitCount++
case "dependency":
snapshot.DependencyCount++
}
if pkg.Origin == "foreign" {
snapshot.ForeignCount++
}
}
return snapshot, nil
}
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
if !validPackageIdentity(identity) {
return nil, fmt.Errorf("invalid package identity")
}
for _, collector := range linuxSoftwareCollectors() {
if collector.name != packageType {
continue
}
if _, err := exec.LookPath(collector.binary); err != nil {
return nil, fmt.Errorf("%s: %w", packageType, errSoftwareManagerUnavailable)
}
return collector.detail(identity)
}
return nil, fmt.Errorf("unsupported package manager %q", packageType)
}
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
if path == "" || !strings.HasPrefix(path, "/") || strings.ContainsRune(path, '\x00') {
return nil, fmt.Errorf("invalid executable path")
}
var lastErr error
for _, collector := range linuxSoftwareCollectors() {
if _, err := exec.LookPath(collector.binary); err != nil {
continue
}
owner, err := collector.owner(path)
if err == nil && owner != nil {
return owner, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errSoftwareManagerUnavailable
}
return nil, lastErr
}
func packageCommand(binary string, args ...string) ([]byte, error) {
path, err := exec.LookPath(binary)
if err != nil {
return nil, fmt.Errorf("%s: %w", binary, errSoftwareManagerUnavailable)
}
command := exec.Command(path, args...)
command.Env = []string{
"LC_ALL=C",
"LANG=C",
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
}
output, err := command.Output()
if err != nil {
return nil, fmt.Errorf("%s query failed: %w", binary, err)
}
return output, nil
}
func conciseCommandError(err error) string {
if errors.Is(err, errSoftwareManagerUnavailable) {
return "unavailable"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "query exited " + strconv.Itoa(exitErr.ExitCode())
}
return err.Error()
}
func validPackageIdentity(identity string) bool {
if identity == "" || len(identity) > 255 || (!unicode.IsLetter(rune(identity[0])) && !unicode.IsDigit(rune(identity[0]))) {
return false
}
for _, char := range identity {
if unicode.IsLetter(char) || unicode.IsDigit(char) || strings.ContainsRune("@+_.:-", char) {
continue
}
return false
}
return true
}
func lines(output []byte) []string {
var result []string
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func stringSet(values []string) map[string]bool {
set := make(map[string]bool, len(values))
for _, value := range values {
fields := strings.Fields(value)
if len(fields) > 0 {
set[fields[0]] = true
}
}
return set
}
func splitPackageList(value string) []string {
value = strings.TrimSpace(value)
if value == "" || value == "None" || value == "(none)" {
return nil
}
fields := strings.Fields(value)
result := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(strings.TrimSuffix(field, ","))
if field != "" {
result = append(result, field)
}
}
return result
}
func parseColonRecord(output []byte) map[string]string {
record := make(map[string]string)
lastKey := ""
for _, raw := range strings.Split(string(output), "\n") {
if raw == "" {
continue
}
if (raw[0] == ' ' || raw[0] == '\t') && lastKey != "" {
continuation := strings.TrimSpace(raw)
if continuation != "" {
record[lastKey] = strings.TrimSpace(record[lastKey] + " " + continuation)
}
continue
}
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 {
lastKey = ""
continue
}
lastKey = strings.TrimSpace(parts[0])
record[lastKey] = strings.TrimSpace(parts[1])
}
return record
}
func parseHumanSize(value string) uint64 {
fields := strings.Fields(strings.ReplaceAll(value, ",", ""))
if len(fields) == 0 {
return 0
}
amount, err := strconv.ParseFloat(fields[0], 64)
if err != nil || amount < 0 {
return 0
}
multiplier := float64(1)
if len(fields) > 1 {
switch strings.ToLower(fields[1]) {
case "kib", "kb":
multiplier = 1024
case "mib", "mb":
multiplier = 1024 * 1024
case "gib", "gb":
multiplier = 1024 * 1024 * 1024
case "tib", "tb":
multiplier = 1024 * 1024 * 1024 * 1024
}
}
return uint64(amount * multiplier)
}
func boundedFiles(values []string) ([]string, int, bool) {
count := len(values)
if count <= MaxPackageFiles {
return values, count, false
}
return values[:MaxPackageFiles], count, true
}
func pacmanInventory() ([]InstalledPackage, error) {
installed, err := packageCommand("pacman", "-Q")
if err != nil {
return nil, err
}
explicitOutput, explicitErr := packageCommand("pacman", "-Qqe")
dependencyOutput, dependencyErr := packageCommand("pacman", "-Qqd")
foreignOutput, foreignErr := packageCommand("pacman", "-Qm")
explicit := map[string]bool{}
dependencies := map[string]bool{}
foreign := map[string]bool{}
if explicitErr == nil {
explicit = stringSet(lines(explicitOutput))
}
if dependencyErr == nil {
dependencies = stringSet(lines(dependencyOutput))
}
if foreignErr == nil {
foreign = stringSet(lines(foreignOutput))
}
var packages []InstalledPackage
for _, line := range lines(installed) {
fields := strings.Fields(line)
if len(fields) != 2 {
continue
}
reason := "unknown"
switch {
case explicit[fields[0]]:
reason = "explicit"
case dependencies[fields[0]]:
reason = "dependency"
}
origin := "repository"
if foreign[fields[0]] {
origin = "foreign"
}
packages = append(packages, InstalledPackage{
PackageType: "pacman", Identity: fields[0], Name: fields[0], Version: fields[1],
InstallReason: reason, Origin: origin,
})
}
return packages, nil
}
func pacmanDetail(identity string) (*PackageDetail, error) {
output, err := packageCommand("pacman", "-Qi", "--color=never", identity)
if err != nil {
return nil, err
}
record := parseColonRecord(output)
origin := "repository"
if foreign, err := packageCommand("pacman", "-Qm", identity); err == nil && len(lines(foreign)) > 0 {
origin = "foreign"
}
reason := "unknown"
if strings.EqualFold(record["Install Reason"], "Explicitly installed") {
reason = "explicit"
} else if strings.Contains(strings.ToLower(record["Install Reason"]), "dependency") {
reason = "dependency"
}
detail := &PackageDetail{InstalledPackage: InstalledPackage{
PackageType: "pacman", Identity: identity, Name: record["Name"], Version: record["Version"],
Architecture: record["Architecture"], Description: record["Description"], InstallReason: reason,
Origin: origin, InstalledSizeBytes: parseHumanSize(record["Installed Size"]), InstalledAt: record["Install Date"],
}, URL: record["URL"], Licenses: splitPackageList(record["Licenses"]), Groups: splitPackageList(record["Groups"]),
DependsOn: splitPackageList(record["Depends On"]), OptionalDependencies: splitPackageList(record["Optional Deps"]),
RequiredBy: splitPackageList(record["Required By"]), Provides: splitPackageList(record["Provides"]),
ConflictsWith: splitPackageList(record["Conflicts With"]), Replaces: splitPackageList(record["Replaces"]),
Packager: record["Packager"], BuildDate: record["Build Date"],
}
if detail.Name == "" {
detail.Name = identity
}
if output, err := packageCommand("pacman", "-Ql", identity); err == nil {
prefix := identity + " "
var files []string
for _, line := range lines(output) {
files = append(files, strings.TrimPrefix(line, prefix))
}
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(files)
}
return detail, nil
}
func pacmanOwner(path string) (*SoftwareOwner, error) {
output, err := packageCommand("pacman", "-Qoq", path)
if err != nil {
return nil, err
}
names := lines(output)
if len(names) == 0 {
return nil, fmt.Errorf("no pacman package owns path")
}
return &SoftwareOwner{PackageType: "pacman", PackageName: names[0]}, nil
}
func dpkgInventory() ([]InstalledPackage, error) {
output, err := packageCommand("dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\t${binary:Synopsis}\n")
if err != nil {
return nil, err
}
manual := map[string]bool{}
if manualOutput, err := packageCommand("apt-mark", "showmanual"); err == nil {
manual = stringSet(lines(manualOutput))
}
var packages []InstalledPackage
for _, line := range strings.Split(string(output), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 5 || !strings.HasPrefix(fields[3], "ii") {
continue
}
identity := fields[0]
name := strings.TrimSuffix(identity, ":"+fields[2])
reason := "unknown"
if manual[name] || manual[identity] {
reason = "explicit"
} else if len(manual) > 0 {
reason = "dependency"
}
packages = append(packages, InstalledPackage{
PackageType: "apt", Identity: identity, Name: name, Version: fields[1], Architecture: fields[2],
Description: fields[4], InstallReason: reason, Origin: "unknown",
})
}
return packages, nil
}
func dpkgDetail(identity string) (*PackageDetail, error) {
format := "${binary:Package}\t${Version}\t${Architecture}\t${binary:Synopsis}\t${Homepage}\t${Installed-Size}\t${Maintainer}\t${Depends}\t${Pre-Depends}\t${Recommends}\t${Suggests}\t${Provides}\t${Conflicts}\t${Replaces}\n"
output, err := packageCommand("dpkg-query", "-W", "-f="+format, identity)
if err != nil {
return nil, err
}
fields := strings.Split(strings.TrimRight(string(output), "\n"), "\t")
if len(fields) < 14 {
return nil, fmt.Errorf("dpkg returned incomplete package detail")
}
name := strings.TrimSuffix(fields[0], ":"+fields[2])
reason := "unknown"
if manual, err := packageCommand("apt-mark", "showmanual"); err == nil {
set := stringSet(lines(manual))
if set[name] || set[identity] {
reason = "explicit"
} else {
reason = "dependency"
}
}
installedSize, _ := strconv.ParseUint(strings.TrimSpace(fields[5]), 10, 64)
detail := &PackageDetail{InstalledPackage: InstalledPackage{
PackageType: "apt", Identity: fields[0], Name: name, Version: fields[1], Architecture: fields[2],
Description: fields[3], InstallReason: reason, Origin: "unknown", InstalledSizeBytes: installedSize * 1024,
}, URL: fields[4], Packager: fields[6]}
detail.DependsOn = splitCommaDependencies(strings.Join([]string{fields[7], fields[8]}, ","))
detail.OptionalDependencies = splitCommaDependencies(strings.Join([]string{fields[9], fields[10]}, ","))
detail.Provides = splitCommaDependencies(fields[11])
detail.ConflictsWith = splitCommaDependencies(fields[12])
detail.Replaces = splitCommaDependencies(fields[13])
if output, err := packageCommand("dpkg-query", "-L", identity); err == nil {
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
}
return detail, nil
}
func splitCommaDependencies(value string) []string {
var result []string
for _, dependency := range strings.Split(value, ",") {
dependency = strings.TrimSpace(dependency)
if dependency != "" && dependency != "<none>" {
result = append(result, dependency)
}
}
return result
}
func dpkgOwner(path string) (*SoftwareOwner, error) {
output, err := packageCommand("dpkg-query", "-S", path)
if err != nil {
return nil, err
}
line := strings.SplitN(string(output), "\n", 2)[0]
parts := strings.SplitN(line, ": ", 2)
if len(parts) != 2 || parts[0] == "" {
return nil, fmt.Errorf("dpkg returned no package owner")
}
name := parts[0]
if index := strings.LastIndex(name, ":"); index > 0 {
name = name[:index]
}
return &SoftwareOwner{PackageType: "apt", PackageName: name}, nil
}
func rpmInventory() ([]InstalledPackage, error) {
format := "%{NAME}\t%{NEVRA}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SUMMARY}\t%{SIZE}\t%{INSTALLTIME}\n"
output, err := packageCommand("rpm", "-qa", "--qf", format)
if err != nil {
return nil, err
}
var packages []InstalledPackage
for _, line := range strings.Split(string(output), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 7 {
continue
}
size, _ := strconv.ParseUint(fields[5], 10, 64)
installed := ""
if epoch, err := strconv.ParseInt(fields[6], 10, 64); err == nil && epoch > 0 {
installed = time.Unix(epoch, 0).UTC().Format(time.RFC3339)
}
packages = append(packages, InstalledPackage{
PackageType: "dnf", Identity: fields[1], Name: fields[0], Version: fields[2], Architecture: fields[3],
Description: fields[4], InstallReason: "unknown", Origin: "unknown", InstalledSizeBytes: size, InstalledAt: installed,
})
}
return packages, nil
}
func rpmDetail(identity string) (*PackageDetail, error) {
output, err := packageCommand("rpm", "-qi", identity)
if err != nil {
return nil, err
}
record := parseColonRecord(output)
version := record["Version"]
if release := record["Release"]; release != "" {
version += "-" + release
}
detail := &PackageDetail{InstalledPackage: InstalledPackage{
PackageType: "dnf", Identity: identity, Name: record["Name"], Version: version,
Architecture: record["Architecture"], Description: record["Summary"], InstallReason: "unknown",
Origin: "unknown", InstalledSizeBytes: parseHumanSize(record["Size"]), InstalledAt: record["Install Date"],
}, URL: record["URL"], Licenses: splitPackageList(record["License"]), Packager: record["Packager"], BuildDate: record["Build Date"]}
if output, err := packageCommand("rpm", "-qR", identity); err == nil {
for _, requirement := range lines(output) {
if !strings.HasPrefix(requirement, "rpmlib(") {
detail.DependsOn = append(detail.DependsOn, requirement)
}
}
}
if output, err := packageCommand("rpm", "-q", "--provides", identity); err == nil {
detail.Provides = lines(output)
}
if output, err := packageCommand("rpm", "-q", "--whatrequires", detail.Name); err == nil {
detail.RequiredBy = lines(output)
}
if output, err := packageCommand("rpm", "-ql", identity); err == nil {
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
}
return detail, nil
}
func rpmOwner(path string) (*SoftwareOwner, error) {
output, err := packageCommand("rpm", "-qf", "--qf", "%{NAME}\n", path)
if err != nil {
return nil, err
}
names := lines(output)
if len(names) == 0 {
return nil, fmt.Errorf("rpm returned no package owner")
}
return &SoftwareOwner{PackageType: "dnf", PackageName: names[0]}, nil
}

View file

@ -0,0 +1,20 @@
//go:build !linux
package system
import (
"fmt"
"time"
)
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
return &SoftwareSnapshot{Supported: false, CollectedAt: time.Now().UTC()}, nil
}
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
return nil, fmt.Errorf("software detail not supported on this platform")
}
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
return nil, fmt.Errorf("software ownership not supported on this platform")
}

3
desktop/.qmlls.ini Normal file
View file

@ -0,0 +1,3 @@
[General]
buildDir="/home/casey/Projects/redflag-desktop-health-worktree/desktop/target/cxxqt/qml_modules"
no-cmake-calls=true

4298
desktop/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,19 @@ version = "0.2.9"
edition = "2021"
license = "AGPL-3.0"
publish = false
[build-dependencies]
tauri-build = { version = "2", features = [] }
description = "RedFlag native local-machine operations console"
[dependencies]
anyhow = "1"
cxx = "1"
cxx-qt = "0.9"
cxx-qt-lib = { version = "0.9", features = ["qt_full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
[build-dependencies]
cxx-qt-build = "0.9"
qt-build-utils = "0.9"
[[bin]]
name = "redflag-desktop"

View file

@ -1,13 +1,26 @@
# RedFlag Desktop
Tauri shell for the same RedFlag app in local-agent context.
Native Qt/QML local-machine operations console backed by the RedFlag Agent.
The desktop shell does not read protected config or state files. Its Rust backend talks to
the existing agent local IPC surface:
Desktop is the local scope of the RedFlag domain: machine health, live resources,
processes, connections, storage, services, containers, software, updates, security,
history, and bounded operator intent. RedFlag Web presents the same domain at fleet
scope.
The QML process holds no machine authority and reads no protected Agent state. Its
CXX-Qt bridge talks only to the platform-local Agent transport:
- Linux: `/var/lib/redflag/agent/localapi/redflag-agent.sock`
- Windows: `\\.\pipe\RedFlagAgentLocal`
The frontend entry is `web/index.desktop.html` and `web/src/desktop/main.tsx`.
Fleet-server context should be added to this same app later rather than creating a second
local-only application.
Observation belongs to the Agent. Privileged mutations travel through Agent policy,
a signed mutation envelope, and the RedFlag helper. QML must never acquire a package,
service, container, or reboot escape hatch of its own.
The QML structure inherits useful interaction and typed-bridge work from Souveraine
Updater. That updater does not survive as a separate product or authority path.
The current release pipeline builds Desktop for Linux amd64 with Qt 6. The Windows
transport exists, but no Windows Desktop artifact is published until a native Qt/MSVC
runner owns that build. The web application remains the fleet surface; it is not
embedded into Desktop.

View file

@ -1,3 +1,31 @@
use cxx_qt_build::{CxxQtBuilder, QmlModule};
use qt_build_utils::QmlFile;
fn main() {
tauri_build::build();
let singletons = ["qml/Theme.qml"];
let views = ["qml/LineGraph.qml", "qml/MetricCard.qml", "qml/NavItem.qml"];
let mut files = vec![QmlFile::from("qml/Main.qml")];
files.extend(
singletons
.iter()
.map(|path| QmlFile::from(*path).singleton(true)),
);
files.extend(views.iter().map(|path| QmlFile::from(*path)));
for path in ["qml/Main.qml"]
.iter()
.chain(singletons.iter())
.chain(views.iter())
{
println!("cargo:rerun-if-changed={path}");
}
println!("cargo:rerun-if-changed=src/bridge/machine.rs");
CxxQtBuilder::new_qml_module(QmlModule::new("com.redflag.desktop").qml_files(files))
.qrc_resources(["icons/icon.png"])
.file("src/bridge/machine.rs")
.qt_module("Core")
.qt_module("Gui")
.qt_module("Qml")
.qt_module("Quick")
.build();
}

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
{
"name": "desktop",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

45
desktop/qml/LineGraph.qml Normal file
View file

@ -0,0 +1,45 @@
import QtQuick
Canvas {
id: root
property var values: []
property real ceiling: 100
property color lineColor: Theme.red
property color fillColor: Theme.tint(lineColor, 0.12)
property bool grid: true
onValuesChanged: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onCeilingChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.reset()
if (grid) {
ctx.strokeStyle = Theme.divider
ctx.lineWidth = 1
for (let y = 0; y <= 4; ++y) {
ctx.beginPath(); ctx.moveTo(0, y * height / 4); ctx.lineTo(width, y * height / 4); ctx.stroke()
}
}
if (!values || values.length < 2) return
const top = Math.max(1, ceiling)
const step = width / Math.max(1, values.length - 1)
ctx.beginPath()
for (let i = 0; i < values.length; ++i) {
const x = i * step
const y = height - Math.min(top, Math.max(0, Number(values[i]))) / top * height
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
}
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath()
ctx.fillStyle = fillColor; ctx.fill()
ctx.beginPath()
for (let j = 0; j < values.length; ++j) {
const x2 = j * step
const y2 = height - Math.min(top, Math.max(0, Number(values[j]))) / top * height
if (j === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2)
}
ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.stroke()
}
}

1012
desktop/qml/Main.qml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
import QtQuick
Rectangle {
required property string label
required property string value
property string detail: ""
property color accent: Theme.text
implicitWidth: 180
implicitHeight: 96
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: Theme.divider
Rectangle { width: 3; height: 28; radius: 2; color: parent.accent; anchors.left: parent.left; anchors.leftMargin: 13; anchors.top: parent.top; anchors.topMargin: 16 }
Text { text: parent.label.toUpperCase(); color: Theme.faint; font.pixelSize: 10; font.letterSpacing: 1.1; anchors.left: parent.left; anchors.leftMargin: 26; anchors.top: parent.top; anchors.topMargin: 14 }
Text { text: parent.value; color: parent.accent; font.pixelSize: 22; font.weight: 650; anchors.left: parent.left; anchors.leftMargin: 26; anchors.top: parent.top; anchors.topMargin: 32 }
Text { text: parent.detail; color: Theme.dim; font.pixelSize: Theme.fontMeta; anchors.left: parent.left; anchors.leftMargin: 26; anchors.right: parent.right; anchors.rightMargin: 10; anchors.bottom: parent.bottom; anchors.bottomMargin: 11; elide: Text.ElideRight }
}

52
desktop/qml/NavItem.qml Normal file
View file

@ -0,0 +1,52 @@
import QtQuick
Rectangle {
id: root
required property string label
property string badge: ""
property bool selected: false
signal clicked()
width: parent ? parent.width : 180
height: 38
radius: Theme.radiusSm
color: selected ? Theme.redDim : mouse.containsMouse ? Theme.hover : "transparent"
Rectangle {
width: 3
height: 18
radius: 2
anchors.left: parent.left
anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
color: root.selected ? Theme.red : "transparent"
}
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: root.selected ? Theme.text : Theme.dim
font.pixelSize: Theme.fontBody
font.weight: root.selected ? 600 : 450
}
Rectangle {
visible: root.badge.length > 0
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: Math.max(20, badgeText.implicitWidth + 10)
height: 20
radius: 10
color: Theme.tint(root.selected ? Theme.red : Theme.faint, 0.22)
Text {
id: badgeText
anchors.centerIn: parent
text: root.badge
color: root.selected ? Theme.red : Theme.dim
font.pixelSize: 10
font.weight: 700
}
}
MouseArea { id: mouse; anchors.fill: parent; hoverEnabled: true; onClicked: root.clicked() }
}

45
desktop/qml/Theme.qml Normal file
View file

@ -0,0 +1,45 @@
pragma Singleton
import QtQuick
QtObject {
property bool dark: true
readonly property color window: dark ? "#0b0e13" : "#f3f4f6"
readonly property color rail: dark ? "#10141b" : "#ffffff"
readonly property color surface: dark ? "#151a22" : "#ffffff"
readonly property color raised: dark ? "#1b222d" : "#f8fafc"
readonly property color hover: dark ? "#222b38" : "#eef2f6"
readonly property color divider: dark ? "#252d39" : "#dce1e7"
readonly property color text: dark ? "#eef1f5" : "#171a1f"
readonly property color dim: dark ? "#99a2af" : "#5f6874"
readonly property color faint: dark ? "#66707d" : "#8a939e"
readonly property color red: "#e23b3b"
readonly property color redDim: dark ? "#3a1a1e" : "#fee2e2"
readonly property color good: "#32bd74"
readonly property color warn: "#e5a936"
readonly property color bad: "#ef5350"
readonly property color blue: "#4d8dff"
readonly property color purple: "#ad7aff"
readonly property color cyan: "#30b8c9"
readonly property int railWidth: 216
readonly property int headerHeight: 72
readonly property int radius: 10
readonly property int radiusSm: 6
readonly property int spacing: 14
readonly property int spacingSm: 8
readonly property int fontTitle: 23
readonly property int fontHeading: 17
readonly property int fontBody: 13
readonly property int fontMeta: 11
readonly property string mono: {
const wanted = ["JetBrainsMono NF", "Adwaita Mono", "DejaVu Sans Mono"]
const installed = Qt.fontFamilies()
for (let i = 0; i < wanted.length; ++i)
if (installed.indexOf(wanted[i]) >= 0) return wanted[i]
return "monospace"
}
function tint(color, alpha) { return Qt.rgba(color.r, color.g, color.b, alpha) }
function pressure(percent) { return percent >= 90 ? bad : percent >= 75 ? warn : good }
}

View file

@ -0,0 +1,95 @@
use serde_json::Value;
use std::io::{Read, Write};
trait ReadWrite: Read + Write {}
impl<T: Read + Write> ReadWrite for T {}
#[cfg(unix)]
fn connect() -> Result<Box<dyn ReadWrite>, String> {
let path = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
std::os::unix::net::UnixStream::connect(path)
.map(|stream| Box::new(stream) as Box<dyn ReadWrite>)
.map_err(|error| format!("connect RedFlag Agent at {path}: {error}"))
}
#[cfg(windows)]
fn connect() -> Result<Box<dyn ReadWrite>, String> {
let path = r"\\.\pipe\RedFlagAgentLocal";
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map(|stream| Box::new(stream) as Box<dyn ReadWrite>)
.map_err(|error| format!("connect RedFlag Agent at {path}: {error}"))
}
pub fn get(path: &str) -> Result<Value, String> {
request("GET", path, None)
}
/// Ok(None) when the Agent has no such route. An older Agent is not an
/// unreachable one, and rendering it as a connection failure is a lie about
/// which half of the pair is behind.
pub fn get_optional(path: &str) -> Result<Option<Value>, String> {
match raw("GET", path, None) {
Ok((404, _)) => Ok(None),
Ok((status, body)) => decode(status, &body).map(Some),
Err(error) => Err(error),
}
}
pub fn post(path: &str, value: &Value) -> Result<Value, String> {
let body =
serde_json::to_vec(value).map_err(|error| format!("encode Agent request: {error}"))?;
request("POST", path, Some(&body))
}
fn request(method: &str, path: &str, body: Option<&[u8]>) -> Result<Value, String> {
let (status, body) = raw(method, path, body)?;
decode(status, &body)
}
fn decode(status: u16, body: &str) -> Result<Value, String> {
if !(200..300).contains(&status) {
if let Ok(value) = serde_json::from_str::<Value>(body) {
if let Some(error) = value["error"].as_str() {
return Err(error.to_string());
}
}
return Err(format!("Agent returned {status}: {}", body.trim()));
}
serde_json::from_str(body).map_err(|error| format!("decode Agent response: {error}"))
}
fn raw(method: &str, path: &str, body: Option<&[u8]>) -> Result<(u16, String), String> {
let body = body.unwrap_or_default();
let mut stream = connect()?;
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: redflag.local\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(request.as_bytes())
.map_err(|error| format!("write Agent request: {error}"))?;
stream
.write_all(body)
.map_err(|error| format!("write Agent body: {error}"))?;
stream
.flush()
.map_err(|error| format!("flush Agent request: {error}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|error| format!("read Agent response: {error}"))?;
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "Agent response has no HTTP body".to_string())?;
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse::<u16>().ok())
.ok_or_else(|| "Agent response has no status".to_string())?;
Ok((status, body.to_string()))
}

View file

@ -0,0 +1,723 @@
use super::local_api;
use cxx_qt::Threading;
use cxx_qt_lib::QString;
use serde_json::Value;
use std::pin::Pin;
use std::sync::LazyLock;
use std::time::Instant;
static DESKTOP_START: LazyLock<Instant> = LazyLock::new(Instant::now);
#[cxx_qt::bridge]
pub mod ffi {
unsafe extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
unsafe extern "RustQt" {
#[qobject]
#[qml_element]
#[qproperty(bool, connected)]
#[qproperty(QString, connection_error)]
#[qproperty(QString, agent_gap)]
#[qproperty(QString, hostname)]
#[qproperty(QString, machine_subtitle)]
#[qproperty(QString, agent_version)]
#[qproperty(QString, agent_status)]
#[qproperty(QString, enrollment)]
#[qproperty(QString, uptime)]
#[qproperty(f64, cpu_usage)]
#[qproperty(f64, load_1)]
#[qproperty(f64, load_5)]
#[qproperty(f64, load_15)]
#[qproperty(f64, memory_percent)]
#[qproperty(u64, memory_used)]
#[qproperty(u64, memory_total)]
#[qproperty(f64, swap_percent)]
#[qproperty(f64, network_receive)]
#[qproperty(f64, network_transmit)]
#[qproperty(f64, disk_read)]
#[qproperty(f64, disk_write)]
#[qproperty(f64, temperature)]
#[qproperty(i32, process_count)]
#[qproperty(i32, update_count)]
#[qproperty(i32, critical_count)]
#[qproperty(i32, container_count)]
#[qproperty(i32, container_running)]
#[qproperty(i32, container_unhealthy)]
#[qproperty(i32, service_count)]
#[qproperty(i32, service_running)]
#[qproperty(i32, service_failed)]
#[qproperty(QString, health_state)]
#[qproperty(QString, history_json)]
#[qproperty(QString, system_json)]
#[qproperty(QString, processes_json)]
#[qproperty(QString, process_detail_json)]
#[qproperty(QString, software_json)]
#[qproperty(QString, software_detail_json)]
#[qproperty(QString, connections_json)]
#[qproperty(QString, containers_json)]
#[qproperty(QString, services_json)]
#[qproperty(QString, updates_json)]
#[qproperty(QString, security_json)]
#[qproperty(QString, events_json)]
#[qproperty(QString, operation_message)]
#[qproperty(QString, approval_json)]
#[qproperty(i32, software_count)]
#[qproperty(i32, software_explicit_count)]
#[qproperty(i32, software_dependency_count)]
#[qproperty(i32, software_foreign_count)]
#[qproperty(bool, approval_running)]
#[qproperty(bool, telemetry_loading)]
#[qproperty(bool, overview_loading)]
#[qproperty(bool, processes_loading)]
#[qproperty(bool, software_loading)]
type Machine = super::MachineRust;
#[qinvokable]
fn refresh_telemetry(self: Pin<&mut Machine>);
#[qinvokable]
fn refresh_overview(self: Pin<&mut Machine>);
#[qinvokable]
fn refresh_processes(self: Pin<&mut Machine>);
#[qinvokable]
fn refresh_connections(self: Pin<&mut Machine>);
#[qinvokable]
fn load_process(self: Pin<&mut Machine>, pid: i32);
#[qinvokable]
fn refresh_software(self: Pin<&mut Machine>);
#[qinvokable]
fn load_software(self: Pin<&mut Machine>, package_type: &QString, identity: &QString);
#[qinvokable]
fn trigger_scan(self: Pin<&mut Machine>);
#[qinvokable]
fn approve_update(
self: Pin<&mut Machine>,
package_type: &QString,
package_name: &QString,
available_version: &QString,
override_reason: &QString,
);
#[qsignal]
fn telemetry_updated(self: Pin<&mut Machine>);
#[qsignal]
fn overview_updated(self: Pin<&mut Machine>);
#[qsignal]
fn processes_updated(self: Pin<&mut Machine>);
#[qsignal]
fn connections_updated(self: Pin<&mut Machine>);
#[qsignal]
fn process_detail_updated(self: Pin<&mut Machine>);
#[qsignal]
fn software_updated(self: Pin<&mut Machine>);
#[qsignal]
fn software_detail_updated(self: Pin<&mut Machine>);
#[qsignal]
fn operation_finished(self: Pin<&mut Machine>, success: bool);
#[qsignal]
fn approval_finished(self: Pin<&mut Machine>, success: bool);
}
impl cxx_qt::Threading for Machine {}
}
pub struct MachineRust {
connected: bool,
connection_error: QString,
agent_gap: QString,
hostname: QString,
machine_subtitle: QString,
agent_version: QString,
agent_status: QString,
enrollment: QString,
uptime: QString,
cpu_usage: f64,
load_1: f64,
load_5: f64,
load_15: f64,
memory_percent: f64,
memory_used: u64,
memory_total: u64,
swap_percent: f64,
network_receive: f64,
network_transmit: f64,
disk_read: f64,
disk_write: f64,
temperature: f64,
process_count: i32,
update_count: i32,
critical_count: i32,
container_count: i32,
container_running: i32,
container_unhealthy: i32,
service_count: i32,
service_running: i32,
service_failed: i32,
health_state: QString,
history_json: QString,
system_json: QString,
processes_json: QString,
process_detail_json: QString,
software_json: QString,
software_detail_json: QString,
connections_json: QString,
containers_json: QString,
services_json: QString,
updates_json: QString,
security_json: QString,
events_json: QString,
operation_message: QString,
approval_json: QString,
software_count: i32,
software_explicit_count: i32,
software_dependency_count: i32,
software_foreign_count: i32,
approval_running: bool,
telemetry_loading: bool,
overview_loading: bool,
processes_loading: bool,
software_loading: bool,
}
impl Default for MachineRust {
fn default() -> Self {
Self {
connected: false,
connection_error: QString::default(),
agent_gap: QString::default(),
hostname: QString::from("This machine"),
machine_subtitle: QString::from("Waiting for RedFlag Agent"),
agent_version: QString::default(),
agent_status: QString::from("connecting"),
enrollment: QString::from("unknown"),
uptime: QString::default(),
cpu_usage: 0.0,
load_1: 0.0,
load_5: 0.0,
load_15: 0.0,
memory_percent: 0.0,
memory_used: 0,
memory_total: 0,
swap_percent: 0.0,
network_receive: 0.0,
network_transmit: 0.0,
disk_read: 0.0,
disk_write: 0.0,
temperature: 0.0,
process_count: 0,
update_count: 0,
critical_count: 0,
container_count: 0,
container_running: 0,
container_unhealthy: 0,
service_count: 0,
service_running: 0,
service_failed: 0,
health_state: QString::from("observing"),
history_json: QString::from("[]"),
system_json: QString::from("{}"),
processes_json: QString::from("[]"),
process_detail_json: QString::from("{}"),
software_json: QString::from("{}"),
software_detail_json: QString::from("{}"),
connections_json: QString::from("[]"),
containers_json: QString::from("[]"),
services_json: QString::from("[]"),
updates_json: QString::from("[]"),
security_json: QString::from("{}"),
events_json: QString::from("[]"),
operation_message: QString::default(),
approval_json: QString::from("{}"),
software_count: 0,
software_explicit_count: 0,
software_dependency_count: 0,
software_foreign_count: 0,
approval_running: false,
telemetry_loading: false,
overview_loading: false,
processes_loading: false,
software_loading: false,
}
}
}
impl ffi::Machine {
pub fn refresh_telemetry(mut self: Pin<&mut Self>) {
if self.telemetry_loading {
return;
}
self.as_mut().set_telemetry_loading(true);
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = match local_api::get_optional("/v1/monitor") {
Ok(Some(value)) => Ok(value),
Ok(None) => Ok(Value::Object(serde_json::Map::new())),
Err(error) => Err(error),
};
let _ = thread.queue(move |machine| machine.apply_telemetry(result));
});
}
pub fn refresh_overview(mut self: Pin<&mut Self>) {
if self.overview_loading {
return;
}
self.as_mut().set_overview_loading(true);
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = load_overview();
let _ = thread.queue(move |machine| machine.apply_overview(result));
});
}
pub fn refresh_processes(mut self: Pin<&mut Self>) {
if self.processes_loading {
return;
}
self.as_mut().set_processes_loading(true);
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::get("/v1/processes");
let _ = thread.queue(move |machine| machine.apply_processes(result));
});
}
pub fn refresh_connections(self: Pin<&mut Self>) {
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::get("/v1/connections");
let _ = thread.queue(move |machine| machine.apply_connections(result));
});
}
pub fn load_process(self: Pin<&mut Self>, pid: i32) {
if pid <= 0 {
return;
}
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::get(&format!("/v1/processes/{pid}"));
let _ = thread.queue(move |machine| machine.apply_process_detail(result));
});
}
pub fn refresh_software(mut self: Pin<&mut Self>) {
if self.software_loading {
return;
}
self.as_mut().set_software_loading(true);
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::get("/v1/software");
let _ = thread.queue(move |machine| machine.apply_software(result));
});
}
pub fn load_software(self: Pin<&mut Self>, package_type: &QString, identity: &QString) {
let package_type = url_component(&package_type.to_string());
let identity = url_component(&identity.to_string());
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::get(&format!(
"/v1/software/detail?manager={package_type}&identity={identity}"
));
let _ = thread.queue(move |machine| machine.apply_software_detail(result));
});
}
pub fn trigger_scan(mut self: Pin<&mut Self>) {
self.as_mut()
.set_operation_message(QString::from("Scan requested"));
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::post(
"/v1/actions/trigger-scan",
&Value::Object(Default::default()),
);
let _ = thread.queue(move |machine| machine.apply_operation(result));
});
}
// Standalone approval. The Agent runs dry-run, closure hash resolution, OSV,
// mint, and helper execution; this only carries the request and renders the
// verdict it gets back. No package-manager path exists in this process.
pub fn approve_update(
mut self: Pin<&mut Self>,
package_type: &QString,
package_name: &QString,
available_version: &QString,
override_reason: &QString,
) {
if self.approval_running {
return;
}
let request = serde_json::json!({
"package_type": package_type.to_string(),
"package_name": package_name.to_string(),
"available_version": available_version.to_string(),
// Asserted from the session that opened the socket, not attested.
// ARCH-002 leaves fresh StepUp and real attribution unfinished.
"operator": std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "local".into()),
"override_reason": override_reason.to_string(),
});
self.as_mut().set_approval_running(true);
self.as_mut().set_approval_json(QString::from("{}"));
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::post("/v1/actions/approve-update", &request);
let _ = thread.queue(move |machine| machine.apply_approval(result));
});
}
fn apply_approval(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_approval_running(false);
let success = result.is_ok();
let payload = match result {
Ok(value) => value,
Err(error) => serde_json::json!({ "error": error }),
};
self.as_mut().set_approval_json(json_string(&payload));
self.approval_finished(success);
}
fn apply_telemetry(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_telemetry_loading(false);
match result {
Ok(value) => {
let cpu = &value["cpu"];
let memory = &value["memory"];
let network = &value["network"];
let storage = &value["storage"];
self.as_mut().set_cpu_usage(number(cpu, "usage_percent"));
self.as_mut().set_load_1(number(cpu, "load_1"));
self.as_mut().set_load_5(number(cpu, "load_5"));
self.as_mut().set_load_15(number(cpu, "load_15"));
self.as_mut()
.set_memory_percent(number(memory, "used_percent"));
self.as_mut().set_memory_used(integer(memory, "used_bytes"));
self.as_mut()
.set_memory_total(integer(memory, "total_bytes"));
self.as_mut()
.set_swap_percent(number(memory, "swap_percent"));
self.as_mut()
.set_network_receive(number(network, "receive_bytes_per_second"));
self.as_mut()
.set_network_transmit(number(network, "transmit_bytes_per_second"));
self.as_mut()
.set_disk_read(number(storage, "read_bytes_per_second"));
self.as_mut()
.set_disk_write(number(storage, "write_bytes_per_second"));
let hottest = value["thermals"]
.as_array()
.into_iter()
.flatten()
.filter_map(|reading| reading["celsius"].as_f64())
.fold(0.0_f64, f64::max);
self.as_mut().set_temperature(hottest);
self.as_mut()
.set_history_json(json_string(&value["history"]));
self.as_mut().set_connected(true);
self.as_mut().set_connection_error(QString::default());
self.telemetry_updated();
}
Err(error) => self.as_mut().report_connection_error(&error),
}
}
fn apply_overview(mut self: Pin<&mut Self>, result: Result<Overview, String>) {
self.as_mut().set_overview_loading(false);
match result {
Ok(data) => self.as_mut().apply_overview_data(data),
Err(error) => self.as_mut().report_connection_error(&error),
}
}
fn apply_overview_data(mut self: Pin<&mut Self>, data: Overview) {
let gap = if data.missing.is_empty() {
String::new()
} else {
format!(
"This Agent does not serve {}. Update the RedFlag Agent to fill these views — the machine is reachable, the Agent is behind.",
data.missing.join(", ")
)
};
self.as_mut().set_agent_gap(QString::from(gap.as_str()));
let info = &data.system["system"];
let name = text(&data.identity, "display_name")
.or_else(|| text(info, "hostname"))
.unwrap_or_else(|| "This machine".into());
let os = text(info, "os_version")
.or_else(|| text(&data.identity, "os_type"))
.unwrap_or_else(|| "Unknown OS".into());
let model = text(info, "device_model")
.or_else(|| text(info, "device_type"))
.unwrap_or_else(|| "Computer".into());
let registered = data.identity["registered"].as_bool().unwrap_or(false);
let updates = data.updates["updates"]
.as_array()
.cloned()
.unwrap_or_default();
let critical = data.security["critical_updates"].as_i64().unwrap_or(0) as i32;
let memory = info["memory_info"]["used_percent"].as_f64().unwrap_or(0.0);
let disk_pressure = info["disk_info"]
.as_array()
.into_iter()
.flatten()
.any(|disk| disk["used_percent"].as_f64().unwrap_or(0.0) >= 90.0);
let failed_services = data.services["failed"].as_i64().unwrap_or(0) as i32;
let unhealthy = data.containers["unhealthy"].as_i64().unwrap_or(0) as i32;
let degraded = data.security["degraded_mode"].as_bool().unwrap_or(false);
let health = if degraded
|| critical > 0
|| memory >= 90.0
|| disk_pressure
|| failed_services > 0
|| unhealthy > 0
{
"degraded"
} else {
"healthy"
};
self.as_mut().set_hostname(QString::from(name.as_str()));
self.as_mut()
.set_machine_subtitle(QString::from(format!("{model} · {os}").as_str()));
self.as_mut()
.set_agent_version(qtext(&data.identity, "agent_version"));
self.as_mut()
.set_agent_status(qtext(&data.status, "agent_status"));
self.as_mut().set_enrollment(QString::from(if registered {
"fleet enrolled"
} else {
"standalone"
}));
self.as_mut().set_uptime(qtext(info, "uptime"));
self.as_mut()
.set_process_count(info["running_processes"].as_i64().unwrap_or(0) as i32);
self.as_mut().set_update_count(updates.len() as i32);
self.as_mut().set_critical_count(critical);
self.as_mut()
.set_container_count(data.containers["count"].as_i64().unwrap_or(0) as i32);
self.as_mut()
.set_container_running(data.containers["running"].as_i64().unwrap_or(0) as i32);
self.as_mut().set_container_unhealthy(unhealthy);
self.as_mut()
.set_service_count(data.services["count"].as_i64().unwrap_or(0) as i32);
self.as_mut()
.set_service_running(data.services["running"].as_i64().unwrap_or(0) as i32);
self.as_mut().set_service_failed(failed_services);
self.as_mut().set_health_state(QString::from(health));
self.as_mut().set_system_json(json_string(info));
self.as_mut()
.set_updates_json(json_string(&Value::Array(updates)));
self.as_mut().set_security_json(json_string(&data.security));
self.as_mut()
.set_containers_json(json_string(&data.containers["containers"]));
self.as_mut()
.set_services_json(json_string(&data.services["services"]));
self.as_mut()
.set_events_json(json_string(&data.events["events"]));
self.as_mut().set_connected(true);
self.as_mut().set_connection_error(QString::default());
self.overview_updated();
}
fn apply_processes(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_processes_loading(false);
match result {
Ok(value) => {
self.as_mut()
.set_processes_json(json_string(&value["processes"]));
self.as_mut()
.set_process_count(value["process_count"].as_i64().unwrap_or(0) as i32);
self.processes_updated();
}
Err(error) => self
.as_mut()
.set_operation_message(QString::from(error.as_str())),
}
}
fn apply_connections(mut self: Pin<&mut Self>, result: Result<Value, String>) {
match result {
Ok(value) => {
self.as_mut()
.set_connections_json(json_string(&value["connections"]));
self.connections_updated();
}
Err(error) => self
.as_mut()
.set_operation_message(QString::from(error.as_str())),
}
}
fn apply_process_detail(mut self: Pin<&mut Self>, result: Result<Value, String>) {
match result {
Ok(value) => self.as_mut().set_process_detail_json(json_string(&value)),
Err(error) => self
.as_mut()
.set_process_detail_json(json_string(&serde_json::json!({"error": error}))),
}
self.process_detail_updated();
}
fn apply_software(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_software_loading(false);
match result {
Ok(value) => {
self.as_mut()
.set_software_count(value["count"].as_i64().unwrap_or(0) as i32);
self.as_mut().set_software_explicit_count(
value["explicit_count"].as_i64().unwrap_or(0) as i32,
);
self.as_mut().set_software_dependency_count(
value["dependency_count"].as_i64().unwrap_or(0) as i32,
);
self.as_mut().set_software_foreign_count(
value["foreign_count"].as_i64().unwrap_or(0) as i32,
);
self.as_mut().set_software_json(json_string(&value));
self.software_updated();
}
Err(error) => self
.as_mut()
.set_operation_message(QString::from(error.as_str())),
}
}
fn apply_software_detail(mut self: Pin<&mut Self>, result: Result<Value, String>) {
let payload = match result {
Ok(value) => value,
Err(error) => serde_json::json!({ "error": error }),
};
self.as_mut()
.set_software_detail_json(json_string(&payload));
self.software_detail_updated();
}
fn apply_operation(mut self: Pin<&mut Self>, result: Result<Value, String>) {
let (success, message) = match result {
Ok(value) => (
value["accepted"].as_bool().unwrap_or(true),
"Agent scan started".to_string(),
),
Err(error) => (false, error),
};
self.as_mut()
.set_operation_message(QString::from(message.as_str()));
self.operation_finished(success);
}
fn report_connection_error(mut self: Pin<&mut Self>, error: &str) {
self.as_mut().set_connected(false);
self.as_mut().set_agent_status(QString::from("unreachable"));
self.as_mut().set_health_state(QString::from("unknown"));
self.as_mut().set_connection_error(QString::from(error));
}
}
struct Overview {
identity: Value,
status: Value,
system: Value,
updates: Value,
security: Value,
containers: Value,
services: Value,
events: Value,
missing: Vec<&'static str>,
}
fn load_overview() -> Result<Overview, String> {
// Presence is a report to the Agent, never a self-declared health verdict.
// The Agent owns liveness and may surface or journal a missing desktop.
let _ = local_api::post(
"/v1/desktop",
&serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": DESKTOP_START.elapsed().as_secs(),
"window_open": true
}),
);
// identity and status are the contract every Agent has served. Everything
// below them arrived later, so a 404 names an Agent that predates the
// endpoint rather than a machine that cannot answer.
let identity = local_api::get("/v1/identity")?;
let status = local_api::get("/v1/status")?;
let mut missing = Vec::new();
let mut optional =
|path: &str, name: &'static str, empty: Value| match local_api::get_optional(path) {
Ok(Some(value)) => value,
Ok(None) => {
missing.push(name);
empty
}
Err(_) => empty,
};
Ok(Overview {
system: optional("/v1/system", "machine health", empty_object()),
updates: optional("/v1/packages", "packages", empty_list("updates")),
security: optional("/v1/security", "security posture", empty_object()),
containers: optional("/v1/containers", "containers", empty_list("containers")),
services: optional("/v1/services", "services", empty_list("services")),
events: optional("/v1/events", "history", empty_list("events")),
missing,
identity,
status,
})
}
fn empty_object() -> Value {
Value::Object(serde_json::Map::new())
}
fn empty_list(key: &str) -> Value {
let mut object = serde_json::Map::new();
object.insert(key.to_string(), Value::Array(Vec::new()));
Value::Object(object)
}
fn number(value: &Value, key: &str) -> f64 {
value[key].as_f64().unwrap_or(0.0)
}
fn integer(value: &Value, key: &str) -> u64 {
value[key].as_u64().unwrap_or(0)
}
fn text(value: &Value, key: &str) -> Option<String> {
value[key]
.as_str()
.filter(|text| !text.is_empty())
.map(str::to_owned)
}
fn qtext(value: &Value, key: &str) -> QString {
QString::from(text(value, key).unwrap_or_default().as_str())
}
fn json_string(value: &Value) -> QString {
QString::from(
serde_json::to_string(value)
.unwrap_or_else(|_| "null".into())
.as_str(),
)
}
fn url_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
use std::fmt::Write;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
encoded
}

View file

@ -0,0 +1,2 @@
pub mod local_api;
pub mod machine;

View file

@ -1,519 +1,38 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QString, QUrl};
#[cfg(unix)]
const LOCAL_SOCKET_PATH: &str = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
#[cfg(windows)]
const LOCAL_PIPE_NAME: &str = r"\\.\pipe\RedFlagAgentLocal";
#[derive(Debug, Serialize, Deserialize)]
struct LocalSnapshot {
identity: IdentityResponse,
status: StatusResponse,
}
#[derive(Debug, Serialize, Deserialize)]
struct IdentityResponse {
agent_id: String,
server_url: String,
hostname: Option<String>,
os_type: Option<String>,
display_name: Option<String>,
organization: Option<String>,
tags: Option<Vec<String>>,
agent_version: String,
config_version: Option<String>,
check_in_interval: i64,
registered: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct StatusResponse {
agent_status: String,
last_check_in: Option<String>,
last_updated: Option<String>,
last_scan_time: Option<String>,
update_count: i64,
summary: UpdateSummary,
scanners: Option<BTreeMap<String, ScannerState>>,
registered: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct UpdateSummary {
total: i64,
by_ecosystem: Option<BTreeMap<String, i64>>,
by_severity: Option<BTreeMap<String, i64>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ScannerState {
name: String,
status: String,
last_scan_time: Option<String>,
last_duration_ms: Option<i64>,
last_error: Option<String>,
update_count: i64,
}
#[derive(Debug, Serialize)]
struct DesktopHealthRequest {
version: String,
uptime_seconds: u64,
window_open: bool,
}
#[derive(Debug, Deserialize)]
struct DesktopHealthResponse {
agent_version: Option<String>,
desktop: Option<DesktopStatus>,
}
#[derive(Debug, Deserialize)]
struct DesktopStatus {
running: bool,
pid: Option<u32>,
enabled: bool,
}
trait ReadWrite: Read + Write {}
impl<T: Read + Write> ReadWrite for T {}
#[tauri::command]
fn local_status() -> Result<LocalSnapshot, String> {
let identity: IdentityResponse = local_get_json("/v1/identity")?;
let status: StatusResponse = local_get_json("/v1/status")?;
Ok(LocalSnapshot { identity, status })
}
#[derive(Debug, Serialize, Deserialize)]
struct UpdateItem {
package_type: String,
package_name: String,
#[serde(default)]
current_version: String,
#[serde(default)]
available_version: String,
#[serde(default)]
severity: String,
#[serde(default)]
cve_list: Vec<String>,
#[serde(default)]
size_bytes: i64,
#[serde(default)]
repository_source: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ScanSnapshot {
#[serde(default)]
last_scan_time: Option<String>,
update_count: i64,
#[serde(default)]
updates: Vec<UpdateItem>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SystemSnapshot {
system: SystemInfo,
#[serde(default)]
top_processes: Vec<TopProcess>,
collected_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct SystemInfo {
hostname: String,
os_type: String,
os_version: String,
os_architecture: String,
ip_address: String,
cpu_info: CpuInfo,
memory_info: MemoryInfo,
#[serde(default)]
disk_info: Vec<DiskInfo>,
running_processes: i64,
uptime: String,
reboot_required: bool,
#[serde(default)]
reboot_reason: String,
#[serde(default)]
device_type: String,
#[serde(default)]
device_model: String,
#[serde(default)]
os_distro: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct CpuInfo {
model_name: String,
cores: i64,
threads: i64,
}
#[derive(Debug, Serialize, Deserialize)]
struct MemoryInfo {
total: u64,
available: u64,
used: u64,
used_percent: f64,
}
#[derive(Debug, Serialize, Deserialize)]
struct DiskInfo {
mountpoint: String,
total: u64,
available: u64,
used: u64,
used_percent: f64,
filesystem: String,
is_root: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct TopProcess {
name: String,
pid: i64,
cpu: f64,
mem: f64,
}
#[tauri::command]
fn list_updates() -> Result<ScanSnapshot, String> {
local_get_json("/v1/packages")
}
#[tauri::command]
fn system_health() -> Result<SystemSnapshot, String> {
local_get_json("/v1/system")
}
#[derive(Debug, Serialize, Deserialize)]
struct TriggerScanResponse {
accepted: bool,
#[serde(default)]
error: Option<String>,
}
#[tauri::command]
fn trigger_scan() -> Result<TriggerScanResponse, String> {
// The agent answers 202 Accepted or 409 Conflict, both with a JSON body.
let response = local_post("/v1/actions/trigger-scan", b"")?;
match response.status {
202 | 409 => serde_json::from_str(&response.body)
.map_err(|err| format!("decode trigger-scan response: {err}")),
status => Err(format!("trigger-scan returned {status}: {}", response.body)),
}
}
// Mirrors the agent's handlers.LocalApproveRequest — the standalone approval
// submission. Operator defaults to the desktop session's user.
#[derive(Debug, Serialize, Deserialize)]
struct ApproveUpdateRequest {
package_type: String,
package_name: String,
#[serde(default)]
available_version: String,
#[serde(default)]
operator: String,
#[serde(default)]
override_reason: String,
}
#[tauri::command]
fn approve_update(mut request: ApproveUpdateRequest) -> Result<serde_json::Value, String> {
if request.operator.is_empty() {
request.operator = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "desktop".to_string());
}
let body_bytes =
serde_json::to_vec(&request).map_err(|err| format!("encode approve-update request: {err}"))?;
let response = local_post("/v1/actions/approve-update", &body_bytes)?;
if response.status == 200 {
return serde_json::from_str(&response.body)
.map_err(|err| format!("decode approve-update response: {err}"));
}
// 409/503/500 carry {"error": "..."} — surface the agent's own message.
let message = serde_json::from_str::<serde_json::Value>(&response.body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or(response.body);
Err(message)
}
fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
let body = local_get(path)?;
serde_json::from_str(&body).map_err(|err| format!("decode local API response: {err}"))
}
fn local_post_json<T: Serialize, R: for<'de> Deserialize<'de>>(path: &str, body: &T) -> Result<R, String> {
let body_bytes = serde_json::to_vec(body).map_err(|err| format!("encode request: {err}"))?;
let response = local_post(path, &body_bytes)?;
if response.status != 200 {
return Err(format!("POST {path} returned {}: {}", response.status, response.body));
}
serde_json::from_str(&response.body).map_err(|err| format!("decode response: {err}"))
}
struct LocalHttpResponse {
status: u16,
body: String,
}
fn local_get(path: &str) -> Result<String, String> {
let mut stream = connect_local_api()?;
let request = format!(
"GET {path} HTTP/1.1\r\nHost: redflag.local\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("write local API request: {err}"))?;
stream
.flush()
.map_err(|err| format!("flush local API request: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("read local API response: {err}"))?;
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "local API response missing HTTP headers".to_string())?;
let status = head
.lines()
.next()
.ok_or_else(|| "local API response missing status line".to_string())?;
if !status.contains(" 200 ") {
return Err(format!("local API returned {status}"));
}
Ok(body.to_string())
}
fn local_post(path: &str, body: &[u8]) -> Result<LocalHttpResponse, String> {
let mut stream = connect_local_api()?;
let request = format!(
"POST {path} HTTP/1.1\r\nHost: redflag.local\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccept: application/json\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("write POST request: {err}"))?;
stream
.write_all(body)
.map_err(|err| format!("write POST body: {err}"))?;
stream
.flush()
.map_err(|err| format!("flush POST request: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("read POST response: {err}"))?;
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "POST response missing HTTP headers".to_string())?;
let status_line = head
.lines()
.next()
.ok_or_else(|| "POST response missing status line".to_string())?;
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.ok_or_else(|| format!("POST response malformed status line: {status_line}"))?;
Ok(LocalHttpResponse {
status,
body: body.to_string(),
})
}
#[cfg(unix)]
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
let stream = std::os::unix::net::UnixStream::connect(LOCAL_SOCKET_PATH).map_err(|err| {
if err.kind() == std::io::ErrorKind::PermissionDenied {
diagnose_socket_permission()
} else {
format!("connect local API socket {LOCAL_SOCKET_PATH}: {err}")
}
})?;
Ok(Box::new(stream))
}
// The local API socket is gated by the redflag-local group. Permission denied
// has exactly two causes worth telling the user apart: the user was never
// added to the group (installer predates the desktop app, or ran headless),
// or the user IS in the group on disk but this login session was stamped
// before the membership existed — groups only apply at login.
#[cfg(unix)]
fn diagnose_socket_permission() -> String {
const GROUP: &str = "redflag-local";
let user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_default();
let on_disk = std::fs::read_to_string("/etc/group")
.ok()
.and_then(|groups| {
groups.lines().find_map(|line| {
let mut fields = line.split(':');
if fields.next() != Some(GROUP) {
return None;
}
let gid = fields.nth(1)?.to_string();
let members = fields.next().unwrap_or_default();
Some((gid, members.split(',').any(|m| m == user)))
})
});
let Some((gid, user_in_group)) = on_disk else {
return format!(
"Cannot reach the RedFlag agent: the {GROUP} group does not exist. \
The agent installer creates it is the agent installed on this machine?"
);
};
let session_has_group = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| {
status.lines().find_map(|line| {
line.strip_prefix("Groups:")
.map(|ids| ids.split_whitespace().any(|id| id == gid))
})
})
.unwrap_or(false);
if user_in_group && !session_has_group {
format!(
"Almost there: {user} is in the {GROUP} group, but this login session \
started before the membership was added. Log out and back in, then \
reopen RedFlag."
)
} else if !user_in_group {
format!(
"Cannot reach the RedFlag agent: {user} is not in the {GROUP} group. \
Run: sudo usermod -aG {GROUP} {user} then log out and back in."
)
} else {
format!(
"connect local API socket {LOCAL_SOCKET_PATH}: permission denied \
(group membership looks correct check socket directory permissions)"
)
}
}
#[cfg(windows)]
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(LOCAL_PIPE_NAME)
.map_err(|err| format!("connect local API pipe {LOCAL_PIPE_NAME}: {err}"))?;
Ok(Box::new(file))
}
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
mod bridge;
fn main() {
let start_time = Instant::now();
let window_open = Arc::new(AtomicBool::new(true));
if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
println!("RedFlag v{}", env!("CARGO_PKG_VERSION"));
return;
}
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
local_status,
system_health,
list_updates,
trigger_scan,
approve_update
])
.setup(move |app| {
let show_i = MenuItem::with_id(app, "show", "Show RedFlag", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
cxx_qt::init_crate!(redflag_desktop);
cxx_qt::init_qml_module!("com.redflag.desktop");
let window_open_clone = window_open.clone();
let window_open_tray = window_open.clone();
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"show" => {
window_open_clone.store(true, Ordering::Relaxed);
show_main_window(app);
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
window_open_tray.store(true, Ordering::Relaxed);
show_main_window(tray.app_handle());
}
})
.build(app)?;
let mut app = QGuiApplication::new();
if let Some(mut app) = app.as_mut() {
app.as_mut()
.set_application_name(&QString::from("com.redflag.Desktop"));
app.as_mut()
.set_application_display_name(&QString::from("RedFlag"));
app.as_mut()
.set_organization_name(&QString::from("RedFlag"));
}
// Hide to tray on close instead of exiting; track visibility.
let window_open_close = window_open.clone();
if let Some(window) = app.get_webview_window("main") {
let value = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
window_open_close.store(false, Ordering::Relaxed);
let _ = value.hide();
}
});
}
let mut engine = QQmlApplicationEngine::new();
if let Some(mut engine) = engine.as_mut() {
engine
.as_mut()
.on_object_creation_failed(|_, url| {
eprintln!("redflag-desktop: QML root failed to construct: {url}");
})
.release();
engine.load(&QUrl::from("qrc:/qt/qml/com/redflag/desktop/qml/Main.qml"));
}
// Spawn health reporting thread — POSTs to /v1/desktop every 30s.
let window_open_health = window_open.clone();
std::thread::spawn(move || {
let version = env!("CARGO_PKG_VERSION").to_string();
loop {
std::thread::sleep(std::time::Duration::from_secs(30));
let req = DesktopHealthRequest {
version: version.clone(),
uptime_seconds: start_time.elapsed().as_secs(),
window_open: window_open_health.load(Ordering::Relaxed),
};
match local_post_json::<_, DesktopHealthResponse>("/v1/desktop", &req) {
Ok(_resp) => {
// Health reported successfully.
}
Err(err) => {
// Best-effort — don't crash if agent is temporarily unavailable.
eprintln!("desktop health report failed: {err}");
}
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error running RedFlag desktop");
if let Some(app) = app.as_mut() {
app.exec();
}
}

View file

@ -1,34 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RedFlag",
"identifier": "com.redflag.local",
"build": {
"beforeDevCommand": "npm --prefix ../web run dev:desktop",
"devUrl": "http://127.0.0.1:3001/index.desktop.html",
"beforeBuildCommand": "npm --prefix ../web run build:desktop",
"frontendDist": "../web/dist-desktop"
},
"app": {
"windows": [
{
"label": "main",
"title": "RedFlag",
"width": 980,
"height": 760,
"minWidth": 760,
"minHeight": 620,
"resizable": true,
"visible": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"icon": [
"icons/icon.png"
]
}
}

View file

@ -110,9 +110,8 @@ if [ -f "$FILE" ]; then
echo " [5] desktop/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
fi
# desktop/tauri.conf.json carries no version field — Tauri inherits it from
# desktop/Cargo.toml (set above). A 4-octet there is not valid semver and Tauri's
# build refuses it, so the desktop version has a single source: the crate.
# Desktop is a Rust/Qt crate. Its Cargo version carries three semver fields;
# release tags and the Go components retain RedFlag's fourth build field.
echo ""

View file

@ -1,15 +1,13 @@
#!/bin/sh
# fetch-desktop-windows.sh — obtain the prebuilt, signed Windows desktop tray
# fetch-desktop-windows.sh — obtain the prebuilt, signed Windows Desktop
# binary from the newest published release and verify it against that release's
# manifest hash.
#
# Why this exists: the agent is Go and cross-compiles to Windows for free
# (Dockerfile bakes redflag-agent.exe directly). The desktop tray is Tauri/Rust
# and its Windows cross-compile drags in the whole MSVC sysroot (~9GB of build
# toolchain). Forcing that into every from-source `docker-compose build` is
# unacceptable — build-from-source is the primary distribution path. So the
# expensive compile lives in CI (release.yml, cargo-xwin) exactly once, and
# from-source servers fetch the resulting signed artifact here.
# Why this exists: the Go agent cross-compiles to Windows without a Windows
# SDK. Native Qt/QML Desktop does not: it needs a matching Qt and MSVC toolchain.
# That build belongs on a native release runner, never inside every source-built
# server image. Until that runner publishes an artifact, this script returns an
# honest optional absence.
#
# Trust: TLS to the forge + the manifest sha256 gate this download, the same
# trust any release download carries. The server re-signs the binary with its
@ -18,14 +16,14 @@
# serving path; it is not the fleet trust root.
#
# The desktop component is OPTIONAL (manifest required:false). Every failure that
# is not active tampering degrades to "no Windows tray" rather than breaking the
# is not active tampering degrades to "no Windows Desktop" rather than breaking the
# build: offline, no release yet, no desktop asset in the release, no manifest
# entry. A hash MISMATCH is the one hard stop — that is tampering, not absence.
set -eu
ARCH="${1:-amd64}"
OUT_DIR="${2:-/out}"
REPO_API="${DESKTOP_RELEASE_REPO_API:-https://codeberg.org/api/v1/repos/Fimeg/RedFlag}"
REPO_API="${DESKTOP_RELEASE_REPO_API:-https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag}"
log() { printf '%s\n' "[INFO] [build] [desktop-fetch] $*" >&2; }
warn() { printf '%s\n' "[WARN] [build] [desktop-fetch] $*" >&2; }
@ -40,25 +38,25 @@ trap 'rm -rf "$work"' EXIT
# would always come back empty. The list endpoint sorts newest-first.
log "querying newest release from $REPO_API"
if ! curl -sfL "$REPO_API/releases?limit=1&draft=false" -o "$work/releases.json"; then
warn "release API unreachable (offline or forge down) — skipping Windows tray"
warn "release API unreachable (offline or forge down) — skipping Windows Desktop"
exit 0
fi
if [ "$(jq 'length' "$work/releases.json" 2>/dev/null || echo 0)" = "0" ]; then
log "no published releases at $REPO_API yet — Windows tray ships once a release exists"
log "no published releases at $REPO_API yet — Windows Desktop ships once a native build exists"
exit 0
fi
manifest_url="$(jq -r '.[0].assets[]? | select(.name == "manifest.json") | .browser_download_url' "$work/releases.json" 2>/dev/null | head -1)"
tag="$(jq -r '.[0].tag_name // empty' "$work/releases.json" 2>/dev/null)"
if [ -z "$manifest_url" ] || [ "$manifest_url" = "null" ]; then
warn "newest release ($tag) has no manifest.json asset — skipping Windows tray"
warn "newest release ($tag) has no manifest.json asset — skipping Windows Desktop"
exit 0
fi
log "newest release: $tag"
if ! curl -sfL "$manifest_url" -o "$work/manifest.json"; then
warn "manifest download failed — skipping Windows tray"
warn "manifest download failed — skipping Windows Desktop"
exit 0
fi
@ -71,7 +69,7 @@ exe_name="$(jq -r --arg a "$ARCH" \
"$work/manifest.json" 2>/dev/null | head -1)"
if [ -z "$expected_sha" ] || [ "$expected_sha" = "null" ]; then
warn "release $tag carries no desktop-windows/$ARCH binary — Windows tray unavailable from this release"
warn "release $tag carries no desktop-windows/$ARCH binary — Windows Desktop unavailable from this release"
exit 0
fi
@ -83,24 +81,24 @@ zip_url="$(jq -r --arg z "$zip_name" \
'.[0].assets[]? | select(.name == $z) | .browser_download_url' \
"$work/releases.json" 2>/dev/null | head -1)"
if [ -z "$zip_url" ] || [ "$zip_url" = "null" ]; then
warn "release $tag has a manifest entry but no $zip_name asset — skipping Windows tray"
warn "release $tag has a manifest entry but no $zip_name asset — skipping Windows Desktop"
exit 0
fi
log "downloading $zip_name"
if ! curl -sfL "$zip_url" -o "$work/win.zip"; then
warn "zip download failed — skipping Windows tray"
warn "zip download failed — skipping Windows Desktop"
exit 0
fi
if ! unzip -o -q "$work/win.zip" -d "$work/unz"; then
warn "zip extraction failed — skipping Windows tray"
warn "zip extraction failed — skipping Windows Desktop"
exit 0
fi
src="$work/unz/$exe_name"
if [ ! -f "$src" ]; then
warn "$exe_name not found inside $zip_name — skipping Windows tray"
warn "$exe_name not found inside $zip_name — skipping Windows Desktop"
exit 0
fi
@ -111,4 +109,4 @@ if [ "$actual_sha" != "$expected_sha" ]; then
fi
cp "$src" "$OUT_DIR/redflag-desktop.exe"
log "verified Windows tray $tag (desktop-windows/$ARCH) -> $OUT_DIR/redflag-desktop.exe"
log "verified Windows Desktop $tag (desktop-windows/$ARCH) -> $OUT_DIR/redflag-desktop.exe"

View file

@ -9,10 +9,6 @@ RUN npm ci --ignore-scripts
COPY web/ ./
RUN npx vite build
# Desktop variant (Tauri frontend) — built here too because this stage has
# Node 20; bookworm's packaged Node 18 is below Vite's floor.
RUN npx tsc && npx vite build --config vite.desktop.config.ts
# Stage 0b: Supply-chain self-attestation. Runs the SAME scripts/dep-scan.sh the
# CI/release pipeline runs, so an operator's own docker-compose build gates its
# dependency supply chain and produces the attested posture embedded into the
@ -154,47 +150,38 @@ RUN rustup target add aarch64-unknown-linux-musl && \
mkdir -p /out/helper-linux-arm64 && \
cp target/aarch64-unknown-linux-musl/release/redflag-helper /out/helper-linux-arm64/redflag-helper
# Stage 2c: Build the Tauri desktop app (system tray + local UI)
# Stage 2c: Build the native Qt/QML Desktop.
FROM rust:1-bookworm AS desktop-builder
ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
libgtk-3-dev \
libsoup-3.0-dev \
libglib2.0-dev \
qt6-base-dev \
qt6-declarative-dev \
qt6-declarative-dev-tools \
libgl1-mesa-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Tauri's build embeds frontendDist (../web/dist-desktop) at compile time —
# take the bundle from the Node 20 stage instead of building it here.
COPY --from=web-builder /web/dist-desktop web/dist-desktop
# Copy desktop Tauri source
# The QML module, bridge, and visual assets are embedded by CXX-Qt.
COPY desktop/ desktop/
# Build the Tauri app in release mode
RUN cd desktop && cargo build --release && \
RUN cd desktop && cargo build --release --locked && \
mkdir -p /out && \
cp target/release/redflag-desktop /out/redflag-desktop
# Stage 2d: Fetch the prebuilt, signed Windows desktop tray from the newest
# Stage 2d: Fetch the prebuilt, signed Windows Desktop from the newest
# release and verify it against the release manifest hash. The Windows desktop
# is Tauri/Rust — cross-compiling it pulls the whole MSVC sysroot (~9GB of build
# toolchain), which has no business in a from-source server build. CI
# (release.yml, cargo-xwin) builds it once where the toolchain is ephemeral;
# this stage fetches the signed result. Optional component: anything short of a
# hash mismatch degrades to "no Windows tray". See scripts/fetch-desktop-windows.sh.
# needs a matching native Qt and MSVC SDK, which has no business in a
# from-source server build. A native release runner will build it once; this
# stage fetches that signed result. Optional component: anything short of a
# hash mismatch degrades to "no Windows Desktop". See scripts/fetch-desktop-windows.sh.
FROM alpine:3.21 AS desktop-windows-fetcher
ARG BUILD_VERSION=dev
ARG DESKTOP_RELEASE_REPO_API=https://codeberg.org/api/v1/repos/Fimeg/RedFlag
ARG DESKTOP_RELEASE_REPO_API=https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag
ENV DESKTOP_RELEASE_REPO_API=${DESKTOP_RELEASE_REPO_API}
RUN apk --no-cache add curl jq unzip
@ -202,8 +189,8 @@ RUN apk --no-cache add curl jq unzip
COPY scripts/fetch-desktop-windows.sh /usr/local/bin/fetch-desktop-windows.sh
# BUILD_VERSION sits in this layer's cache key so bumping the version re-fetches
# rather than reusing a stale tray from an earlier build.
RUN echo "fetch Windows tray for build $BUILD_VERSION" && \
# rather than reusing a stale Desktop from an earlier build.
RUN echo "fetch Windows Desktop for build $BUILD_VERSION" && \
sh /usr/local/bin/fetch-desktop-windows.sh amd64 /out
# Stage 3: Final image with server and all agent binaries
@ -225,21 +212,21 @@ COPY --from=agent-builder /build/binaries ./binaries
COPY --from=helper-builder /out/helper-linux-amd64 ./binaries/helper-linux-amd64
COPY --from=helper-builder /out/helper-linux-arm64 ./binaries/helper-linux-arm64
# Copy the Tauri desktop app (system tray + local UI shell)
# Copy native RedFlag Desktop.
COPY --from=desktop-builder /out/redflag-desktop ./binaries/linux-amd64/redflag-desktop
# Stage in the Windows desktop tray IF the newest release carried one (fetched +
# Stage in Windows Desktop IF the newest release carried one (fetched +
# hash-verified by the desktop-windows-fetcher stage). When absent — no release
# yet, offline build, or a release with no desktop — the server simply serves no
# Windows tray and the installer 404-skips it. The heavy Windows compile never
# Windows Desktop and the installer 404-skips it. The native Windows compile never
# happens here; only the verified artifact lands.
COPY --from=desktop-windows-fetcher /out /opt/winfetch
RUN if [ -f /opt/winfetch/redflag-desktop.exe ]; then \
mkdir -p ./binaries/windows-amd64 && \
mv /opt/winfetch/redflag-desktop.exe ./binaries/windows-amd64/redflag-desktop.exe && \
echo "[INFO] [build] [desktop] staged Windows tray into image"; \
echo "[INFO] [build] [desktop] staged Windows Desktop into image"; \
else \
echo "[INFO] [build] [desktop] no Windows tray staged (none in latest release)"; \
echo "[INFO] [build] [desktop] no Windows Desktop staged (none in latest release)"; \
fi; \
rm -rf /opt/winfetch
@ -253,4 +240,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
EXPOSE 8080
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["./redflag-server"]
CMD ["./redflag-server"]

View file

@ -309,7 +309,7 @@ func main() {
}
}
// Sign desktop binaries (Tauri system tray + local UI shell).
// Sign native Desktop binaries.
// Same pattern as helper: stored under "desktop-<os>", listed in
// the release manifest. Missing binary is non-fatal — installer skips.
desktopArches := []string{"amd64"}
@ -620,7 +620,7 @@ func main() {
// route, same as /manifest. Signed + manifest-verified at install time.
api.GET("/helper/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadHelper)
// Desktop app (Tauri system tray + local UI shell). Optional — 404
// Native Desktop app. Optional — 404
// if not built for this arch, installer skips gracefully.
api.GET("/desktop/:platform/:arch", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadDesktop)

View file

@ -350,7 +350,7 @@ func (h *DownloadHandler) DownloadHelper(c *gin.Context) {
c.File(absPath)
}
// DownloadDesktop serves the Tauri desktop app (system tray + local UI shell).
// DownloadDesktop serves the native Qt/QML local-machine operations console.
// The desktop binary is optional — if not built for a given platform/arch,
// returns 404 gracefully so the installer can skip it.
func (h *DownloadHandler) DownloadDesktop(c *gin.Context) {

View file

@ -166,7 +166,7 @@ if $GUIDED; then
echo ""
echo "This will install the RedFlag agent and its components."
echo "Required components: agent, helper, web (embedded in server)."
echo "Optional components: desktop (system tray + local dashboard)."
echo "Optional components: RedFlag Desktop (native local-machine operations console)."
echo ""
echo "Press Enter to continue or Ctrl-C to abort."
read -r
@ -279,10 +279,10 @@ if [ ! -d "$AGENT_HOME" ]; then
echo " - Logs: $AGENT_LOG_DIR"
fi
# Local-API / tray access chain — enforced on EVERY run, not just first
# install. Upgrades from pre-tray versions carry agent:agent 700 dirs that
# Local-API / Desktop access chain — enforced on EVERY run, not just first
# install. Upgrades from older versions carry agent:agent 700 dirs that
# block the ${LOCAL_API_GROUP} group from traversing to the socket, which
# leaves the tray installed but unable to connect. Idempotent by design.
# leaves Desktop installed but unable to connect. Idempotent by design.
# 710/750: group gets traverse only on the path, read on the socket dir;
# the agent re-asserts socket dir/file ownership itself on startup.
sudo mkdir -p "$AGENT_HOME/localapi"
@ -802,24 +802,20 @@ while IFS='|' read -r comp_name comp_kind comp_req comp_vcmd comp_file comp_sha
[Desktop Entry]
Type=Application
Name=RedFlag
Comment=RedFlag system tray (local agent dashboard)
Comment=RedFlag local-machine operations console
Exec=${INSTALL_DIR}/redflag-desktop
Terminal=false
X-GNOME-Autostart-enabled=true
EOF
sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop"
fi
# GNOME AppIndicator note.
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
echo "[INFO] [installer] [desktop] GNOME detected without AppIndicator extension — install gnome-shell-extension-appindicator"
fi
# Add invoking user to redflag-local group (provisioning: desktop_user_membership).
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
echo "✓ $SUDO_USER already in $LOCAL_API_GROUP group"
else
sudo usermod -aG "$LOCAL_API_GROUP" "$SUDO_USER"
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (tray socket access — re-login to take effect)"
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (Desktop socket access — re-login to take effect)"
fi
fi
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-desktop"

View file

@ -637,7 +637,7 @@ if (-not $SkipServiceInstall) {
Start-Service -Name $ServiceName
}
# Step 7: Download and install desktop app (system tray + local dashboard).
# Step 7: Download and install native RedFlag Desktop.
# The desktop binary is optional — the installer proceeds without it if the
# server has no build for this platform.
$DesktopBinary = "redflag-desktop.exe"
@ -645,7 +645,7 @@ $DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
Write-Host
Write-Host "Downloading desktop app (system tray + local dashboard)..." -ForegroundColor Yellow
Write-Host "Downloading RedFlag Desktop..." -ForegroundColor Yellow
try {
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
@ -677,7 +677,7 @@ try {
}
# Step 8: Register desktop autostart for the interactive user.
# The Run key launches redflag-desktop.exe on logon so the tray icon appears.
# The Run key launches redflag-desktop.exe on interactive logon.
# Must target HKCU (per-user); the installer runs elevated so we resolve the
# original non-elevated user via the calling process chain.
if (Test-Path $DesktopBinaryPath) {

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RedFlag Local Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/desktop/main.tsx"></script>
</body>
</html>

229
web/package-lock.json generated
View file

@ -11,7 +11,6 @@
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.19.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
@ -25,7 +24,6 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
@ -1298,233 +1296,6 @@
"react": "^18 || ^19"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View file

@ -6,16 +6,13 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:desktop": "vite --config vite.desktop.config.ts --host 127.0.0.1",
"build": "tsc && vite build",
"build:desktop": "tsc && vite build --config vite.desktop.config.ts",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.19.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
@ -29,7 +26,6 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",

View file

@ -1,664 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { formatRelativeTime } from '@/lib/utils'
// ---------- theme palette ----------
type Theme = 'dark' | 'light'
interface Palette {
bg: string
bgAlt: string
bgHeader: string
border: string
borderInner: string
text: string
textMuted: string
textDim: string
accent: string
good: string
warn: string
bad: string
errorBg: string
errorBorder: string
errorText: string
}
const DARK: Palette = {
bg: '#0f1117',
bgAlt: '#161b27',
bgHeader: '#161b27',
border: '#1e2535',
borderInner: '#1a1f2e',
text: '#d1d5db',
textMuted: '#9ca3af',
textDim: '#6b7280',
accent: '#dc2626',
good: '#22c55e',
warn: '#f59e0b',
bad: '#ef4444',
errorBg: '#2d1b1b',
errorBorder: '#7f1d1d',
errorText: '#fca5a5',
}
const LIGHT: Palette = {
bg: '#f9fafb',
bgAlt: '#ffffff',
bgHeader: '#f3f4f6',
border: '#e5e7eb',
borderInner: '#f3f4f6',
text: '#374151',
textMuted: '#6b7280',
textDim: '#9ca3af',
accent: '#dc2626',
good: '#16a34a',
warn: '#d97706',
bad: '#dc2626',
errorBg: '#fef2f2',
errorBorder: '#fecaca',
errorText: '#991b1b',
}
// ---------- API types ----------
interface LocalIdentity {
agent_id: string
server_url: string
hostname?: string
os_type?: string
display_name?: string
organization?: string
tags?: string[]
agent_version: string
config_version?: string
check_in_interval: number
registered: boolean
}
interface UpdateSummary {
total: number
by_ecosystem?: Record<string, number>
by_severity?: Record<string, number>
}
interface ScannerState {
name: string
status: string
last_scan_time?: string
last_duration_ms?: number
last_error?: string
update_count: number
}
interface LocalStatus {
agent_status: string
last_check_in?: string
last_updated?: string
last_scan_time?: string
update_count: number
summary: UpdateSummary
scanners?: Record<string, ScannerState>
registered: boolean
}
interface LocalSnapshot {
identity: LocalIdentity
status: LocalStatus
}
interface UpdateItem {
package_type: string
package_name: string
current_version: string
available_version: string
severity: string
cve_list: string[]
size_bytes: number
repository_source: string
}
interface ScanSnapshot {
last_scan_time?: string
update_count: number
updates: UpdateItem[]
}
interface SystemInfo {
hostname: string
os_type: string
os_version: string
os_architecture: string
ip_address: string
cpu_info: { model_name: string; cores: number; threads: number }
memory_info: { total: number; available: number; used: number; used_percent: number }
disk_info: Array<{
mountpoint: string
total: number
available: number
used: number
used_percent: number
filesystem: string
is_root: boolean
}>
running_processes: number
uptime: string
reboot_required: boolean
reboot_reason: string
device_type: string
device_model: string
os_distro: string
}
interface SystemSnapshot {
system: SystemInfo
top_processes: Array<{ name: string; pid: number; cpu: number; mem: number }>
collected_at: string
}
interface TriggerScanResponse {
accepted: boolean
error?: string
}
interface ApprovePolicy {
decision: string
reason: string
executed: boolean
verified_artifacts: number
exit_code: number
error?: string
}
interface ApproveResult {
request_id: string
osv_status: string
osv_vuln_count: number
closure_size: number
policy: ApprovePolicy | null
}
// Local approval is gated to the ecosystems whose closures the agent can
// resolve and pin — mirrors gatedLocalApproval in the agent.
const APPROVABLE = new Set(['dnf', 'apt'])
type HealthState = 'healthy' | 'warning' | 'error'
// ---------- main component ----------
const LocalAgentApp: React.FC = () => {
const [theme, setTheme] = useState<Theme>('dark')
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
const [system, setSystem] = useState<SystemSnapshot | null>(null)
const [updates, setUpdates] = useState<UpdateItem[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
const [scanState, setScanState] = useState<'idle' | 'requesting' | 'running'>('idle')
const [scanNote, setScanNote] = useState<string | null>(null)
const p = theme === 'dark' ? DARK : LIGHT
const load = useCallback(async () => {
try {
setError(null)
const [next, health, scan] = await Promise.all([
invoke<LocalSnapshot>('local_status'),
invoke<SystemSnapshot>('system_health'),
invoke<ScanSnapshot>('list_updates'),
])
setSnapshot(next)
setSystem(health)
setLastRefresh(new Date())
setUpdates(scan.updates ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const id = window.setInterval(load, 15000)
return () => window.clearInterval(id)
}, [load])
const triggerScan = useCallback(async () => {
setScanState('requesting')
setScanNote(null)
try {
const resp = await invoke<TriggerScanResponse>('trigger_scan')
if (resp.accepted) {
setScanState('running')
// The scan runs agent-side; the 5s poll picks up results. Clear the
// running indicator after a grace window rather than tracking scan
// completion state the local API doesn't expose per-request.
window.setTimeout(() => setScanState('idle'), 20000)
} else {
setScanState('idle')
setScanNote(resp.error || 'scan not accepted')
}
} catch (err) {
setScanState('idle')
setScanNote(err instanceof Error ? err.message : String(err))
}
}, [])
// Standalone (unregistered) is a first-class posture, not a degraded state —
// only fleet-joined agents that lose their status report warn on registration.
const health = useMemo<HealthState>(() => {
if (error || !snapshot) return 'error'
if (snapshot.identity.registered && !snapshot.status.registered) return 'warning'
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online' && snapshot.identity.registered) return 'warning'
if (system?.system.reboot_required) return 'warning'
if ((system?.system.memory_info.used_percent ?? 0) >= 90) return 'warning'
if (system?.system.disk_info.some(disk => disk.used_percent >= 90)) return 'warning'
return (snapshot.status.summary.by_severity?.critical ?? 0) > 0 ? 'warning' : 'healthy'
}, [error, snapshot, system])
const scanners = useMemo(() => {
if (!snapshot?.status.scanners) return []
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
}, [snapshot])
const healthDot = health === 'healthy' ? p.good : health === 'warning' ? p.warn : p.bad
const healthLabel = health === 'healthy' ? 'Online' : health === 'warning' ? 'Warning' : 'Error'
const rootDisk = system?.system.disk_info.find(disk => disk.is_root)
?? system?.system.disk_info[0]
const memoryColor = (system?.system.memory_info.used_percent ?? 0) >= 90
? p.bad
: (system?.system.memory_info.used_percent ?? 0) >= 80 ? p.warn : p.good
const diskColor = (rootDisk?.used_percent ?? 0) >= 90
? p.bad
: (rootDisk?.used_percent ?? 0) >= 80 ? p.warn : p.good
return (
<div style={{ background: p.bg, color: p.text, fontFamily: "'Inter', system-ui, sans-serif", minHeight: '100vh', fontSize: '13px' }}>
{/* RedFlag is the product; this computer is the first surface. */}
<div style={{ background: p.bgHeader, borderBottom: `1px solid ${p.border}`, padding: '14px 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '4px', height: '28px', background: p.accent, borderRadius: '2px' }} />
<div>
<div style={{ fontWeight: 750, fontSize: '19px', letterSpacing: '-0.02em', color: theme === 'dark' ? '#f9fafb' : '#111827' }}>RedFlag</div>
<div style={{ color: p.textDim, fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.12em' }}>This Computer</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button
onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
title={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px 4px', fontSize: '11px', lineHeight: 1, borderRadius: '2px' }}
>
{theme === 'dark' ? '☀' : '☾'}
</button>
<button
onClick={load}
title="Refresh"
style={{ background: 'none', border: 'none', color: p.textDim, cursor: 'pointer', padding: '2px', lineHeight: 1 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', opacity: loading ? 0.4 : 1 }}>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
</button>
</div>
</div>
<div style={{ padding: '18px', borderBottom: `1px solid ${p.border}`, background: p.bgAlt }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', alignItems: 'flex-start' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '9px', height: '9px', borderRadius: '50%', background: healthDot }} />
<span style={{ fontSize: '20px', fontWeight: 650, color: p.text }}>
{snapshot?.identity.display_name || system?.system.hostname || snapshot?.identity.hostname || 'This computer'}
</span>
</div>
<div style={{ marginTop: '4px', color: p.textMuted, fontSize: '12px' }}>
{system?.system.device_model || system?.system.device_type || 'Computer'} · {system?.system.os_version || snapshot?.identity.os_type || 'Unknown OS'} · {healthLabel}
</div>
</div>
{system?.system.reboot_required && (
<div style={{ border: `1px solid ${p.warn}`, color: p.warn, borderRadius: '4px', padding: '5px 9px', fontSize: '11px' }}>
Reboot required{system.system.reboot_reason ? ` · ${system.system.reboot_reason}` : ''}
</div>
)}
</div>
</div>
{error && (
<div style={{ background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderLeft: `3px solid ${p.bad}`, margin: '10px 12px', padding: '7px 10px', borderRadius: '3px', fontSize: '12px', color: p.errorText, fontFamily: 'monospace' }}>
{error}
</div>
)}
{/* System health is the landing view. */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: '1px', background: p.border, borderBottom: `1px solid ${p.border}` }}>
<StatCell p={p} label="Memory" value={system ? `${system.system.memory_info.used_percent.toFixed(0)}%` : '—'} accent={memoryColor} />
<StatCell p={p} label="Root disk" value={rootDisk ? `${rootDisk.used_percent.toFixed(0)}%` : '—'} accent={diskColor} />
<StatCell p={p} label="Processes" value={system ? String(system.system.running_processes) : '—'} accent={p.text} />
<StatCell p={p} label="Uptime" value={system?.system.uptime || '—'} accent={p.text} />
<StatCell p={p} label="Updates" value={String(snapshot?.status.update_count ?? 0)} accent={(snapshot?.status.update_count ?? 0) > 0 ? p.warn : p.good} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', borderBottom: `1px solid ${p.border}` }}>
<Section p={p} label="Computer health">
<Row p={p} label="Processor" value={system?.system.cpu_info.model_name || '—'} />
<Row p={p} label="CPU" value={system ? `${system.system.cpu_info.cores} cores · ${system.system.cpu_info.threads} threads` : '—'} />
<Row p={p} label="Memory" value={system ? `${formatBytes(system.system.memory_info.used)} of ${formatBytes(system.system.memory_info.total)}` : '—'} />
<Row p={p} label="Root storage" value={rootDisk ? `${formatBytes(rootDisk.used)} of ${formatBytes(rootDisk.total)} · ${rootDisk.filesystem}` : '—'} />
<Row p={p} label="Network" value={system?.system.ip_address || '—'} mono />
</Section>
<Section p={p} label="Top processes">
{(system?.top_processes.length ?? 0) === 0 ? (
<div style={{ padding: '10px 14px', color: p.textDim, fontSize: '12px' }}>Process data unavailable.</div>
) : system?.top_processes.slice(0, 6).map(process => (
<div key={process.pid} style={{ display: 'grid', gridTemplateColumns: '1fr 54px 54px', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, fontSize: '11px' }}>
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{process.name}</span>
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.cpu.toFixed(1)}% CPU</span>
<span style={{ color: p.textDim, textAlign: 'right' }}>{process.mem.toFixed(1)}% RAM</span>
</div>
))}
</Section>
</div>
{/* Pending updates — the standalone admin surface */}
<UpdatesSection
p={p}
updates={updates}
standalone={snapshot ? !snapshot.identity.registered : false}
scanState={scanState}
scanNote={scanNote}
onScan={triggerScan}
onChanged={load}
/>
{/* Identity */}
<Section p={p} label="Identity">
<Row p={p} label="Host" value={snapshot?.identity.hostname || '—'} mono />
<Row p={p} label="Agent" value={snapshot ? snapshot.identity.agent_id.slice(0, 16) + '…' : '—'} mono />
<Row p={p} label="Server" value={snapshot?.identity.server_url || '—'} />
<Row p={p} label="Version" value={snapshot?.identity.agent_version || '—'} mono />
<Row p={p} label="Platform" value={snapshot?.identity.os_type || '—'} />
</Section>
{/* Activity */}
<Section p={p} label="Activity">
<Row p={p} label="Last check-in" value={maybeRelative(snapshot?.status.last_check_in)} />
<Row p={p} label="Last scan" value={maybeRelative(snapshot?.status.last_scan_time)} />
<Row p={p} label="Status" value={snapshot?.status.agent_status || 'unknown'} accent={snapshot?.status.agent_status === 'online' ? p.good : p.warn} />
</Section>
{/* Scanners */}
{scanners.length > 0 && (
<Section p={p} label={`Scanners (${scanners.length})`}>
{scanners.map(scanner => (
<div key={scanner.name} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', padding: '5px 14px', borderBottom: `1px solid ${p.borderInner}` }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', marginTop: '4px', flexShrink: 0, background: scanner.status === 'success' ? p.good : scanner.status === 'failed' ? p.bad : p.textDim }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text }}>{scanner.name}</span>
<span style={{ fontSize: '11px', color: p.textDim }}>{scanner.update_count} pkgs</span>
</div>
<div style={{ fontSize: '11px', color: scanner.last_error ? p.bad : p.textDim, marginTop: '1px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{scanner.last_error || maybeRelative(scanner.last_scan_time)}
</div>
</div>
</div>
))}
</Section>
)}
{/* Footer */}
<div style={{ padding: '8px 14px', borderTop: `1px solid ${p.border}`, display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: p.textDim }}>
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting data'}</span>
<span style={{ fontFamily: 'monospace' }}>v{snapshot?.identity.agent_version ?? '—'}</span>
</div>
</div>
)
}
// ---------- sub-components ----------
interface WithPalette { p: Palette }
function severityColor(p: Palette, severity: string): string {
const s = severity.toLowerCase()
if (s === 'critical') return p.bad
if (s === 'high' || s === 'important') return p.warn
if (s === 'medium' || s === 'moderate') return p.accent
return p.textDim
}
const SEVERITY_ORDER: Record<string, number> = {
critical: 0, high: 1, important: 1, medium: 2, moderate: 2, low: 3,
}
interface UpdatesSectionProps extends WithPalette {
updates: UpdateItem[]
standalone: boolean
scanState: 'idle' | 'requesting' | 'running'
scanNote: string | null
onScan: () => void
onChanged: () => void
}
const UpdatesSection: React.FC<UpdatesSectionProps> = ({ p, updates, standalone, scanState, scanNote, onScan, onChanged }) => {
const sorted = useMemo(
() =>
[...updates].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4
const sb = SEVERITY_ORDER[b.severity.toLowerCase()] ?? 4
return sa !== sb ? sa - sb : a.package_name.localeCompare(b.package_name)
}),
[updates],
)
return (
<div style={{ borderBottom: `1px solid ${p.border}` }}>
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '2px', height: '10px', background: p.accent, borderRadius: '1px', flexShrink: 0 }} />
<span style={{ fontSize: '10px', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: p.textMuted }}>
Updates ({updates.length})
</span>
<div style={{ flex: 1 }} />
<button
onClick={onScan}
disabled={scanState !== 'idle'}
style={{
background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px',
color: scanState === 'idle' ? p.text : p.textDim, cursor: scanState === 'idle' ? 'pointer' : 'default',
padding: '1px 8px', fontSize: '10px', textTransform: 'uppercase' as const, letterSpacing: '0.06em',
}}
>
{scanState === 'idle' ? 'Scan now' : scanState === 'requesting' ? 'Requesting…' : 'Scanning…'}
</button>
</div>
{scanNote && (
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.warn, borderBottom: `1px solid ${p.borderInner}` }}>{scanNote}</div>
)}
{!standalone && updates.length > 0 && (
<div style={{ padding: '4px 14px', fontSize: '11px', color: p.textDim, borderBottom: `1px solid ${p.borderInner}` }}>
Fleet-managed approvals happen on the server.
</div>
)}
{sorted.length === 0 ? (
<div style={{ padding: '8px 14px', fontSize: '12px', color: p.textDim }}>No pending updates.</div>
) : (
sorted.map(update => (
<UpdateRow key={`${update.package_type}:${update.package_name}`} p={p} update={update} standalone={standalone} onChanged={onChanged} />
))
)}
</div>
)
}
type ApprovePhase = 'idle' | 'confirm' | 'busy' | 'blocked' | 'done' | 'failed'
const UpdateRow: React.FC<WithPalette & { update: UpdateItem; standalone: boolean; onChanged: () => void }> = ({ p, update, standalone, onChanged }) => {
const [phase, setPhase] = useState<ApprovePhase>('idle')
const [message, setMessage] = useState<string | null>(null)
const [overrideReason, setOverrideReason] = useState('')
const [result, setResult] = useState<ApproveResult | null>(null)
const approvable = standalone && APPROVABLE.has(update.package_type)
const approve = useCallback(async (reason: string) => {
setPhase('busy')
setMessage(null)
try {
const res = await invoke<ApproveResult>('approve_update', {
request: {
package_type: update.package_type,
package_name: update.package_name,
available_version: update.available_version,
operator: '',
override_reason: reason,
},
})
setResult(res)
setPhase('done')
onChanged()
} catch (err) {
const text = err instanceof Error ? err.message : String(err)
setMessage(text)
// The gate refuses vulnerable/unreachable OSV verdicts without an
// explicit operator reason — offer the journaled override path.
setPhase(text.includes('override requires an explicit reason') ? 'blocked' : 'failed')
}
}, [update, onChanged])
const sevColor = severityColor(p, update.severity)
return (
<div style={{ borderBottom: `1px solid ${p.borderInner}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 14px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0, background: sevColor }} title={update.severity || 'unknown'} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px' }}>
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: p.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{update.package_name}
</span>
<span style={{ fontSize: '10px', color: p.textDim, flexShrink: 0 }}>{update.package_type}</span>
</div>
<div style={{ fontSize: '11px', color: p.textDim, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{update.current_version || '?'} {update.available_version || '?'}
{update.cve_list.length > 0 && <span style={{ color: sevColor }}> · {update.cve_list.length} CVE</span>}
</div>
</div>
{approvable && phase === 'idle' && (
<button
onClick={() => setPhase('confirm')}
style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, cursor: 'pointer', padding: '2px 8px', fontSize: '11px', flexShrink: 0 }}
>
Install
</button>
)}
{phase === 'busy' && <span style={{ fontSize: '11px', color: p.warn, flexShrink: 0 }}>Verifying & installing</span>}
{phase === 'done' && <span style={{ fontSize: '11px', color: p.good, flexShrink: 0 }}>Installed</span>}
</div>
{phase === 'confirm' && (
<div style={{ padding: '6px 14px 8px 28px', fontSize: '11px', color: p.textMuted }}>
<div style={{ marginBottom: '6px' }}>
Local gates run first (closure resolve, OSV vulnerability check); the install executes through the signed helper and is journaled.
</div>
<button onClick={() => approve(overrideReason)} style={{ background: p.accent, border: 'none', borderRadius: '2px', color: '#fff', cursor: 'pointer', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}>
Approve & install
</button>
<button onClick={() => setPhase('idle')} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Cancel
</button>
</div>
)}
{(phase === 'blocked' || phase === 'failed') && message && (
<div style={{ margin: '2px 14px 8px 28px', padding: '6px 8px', background: p.errorBg, border: `1px solid ${p.errorBorder}`, borderRadius: '3px', fontSize: '11px', color: p.errorText, fontFamily: 'monospace', overflowWrap: 'anywhere' }}>
{message}
</div>
)}
{phase === 'blocked' && (
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px' }}>
<input
value={overrideReason}
onChange={e => setOverrideReason(e.target.value)}
placeholder="Override reason (journaled)"
style={{ width: '100%', boxSizing: 'border-box', background: p.bgAlt, border: `1px solid ${p.border}`, borderRadius: '2px', color: p.text, padding: '4px 6px', fontSize: '11px', marginBottom: '6px' }}
/>
<button
onClick={() => approve(overrideReason)}
disabled={overrideReason.trim() === ''}
style={{ background: overrideReason.trim() ? p.accent : p.border, border: 'none', borderRadius: '2px', color: '#fff', cursor: overrideReason.trim() ? 'pointer' : 'default', padding: '3px 10px', fontSize: '11px', marginRight: '6px' }}
>
Override & install
</button>
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Cancel
</button>
</div>
)}
{phase === 'failed' && (
<div style={{ padding: '0 14px 8px 28px' }}>
<button onClick={() => { setPhase('idle'); setMessage(null) }} style={{ background: 'none', border: `1px solid ${p.border}`, borderRadius: '2px', color: p.textMuted, cursor: 'pointer', padding: '3px 10px', fontSize: '11px' }}>
Dismiss
</button>
</div>
)}
{phase === 'done' && result && (
<div style={{ padding: '0 14px 8px 28px', fontSize: '11px', color: p.textDim }}>
OSV {result.osv_status} · closure {result.closure_size} pkg{result.closure_size === 1 ? '' : 's'} · {result.policy?.decision ?? 'no verdict'}
{' — clears from this list on the next scan'}
</div>
)}
</div>
)
}
const Section: React.FC<WithPalette & { label: string; children: React.ReactNode }> = ({ p, label, children }) => (
<div style={{ borderBottom: `1px solid ${p.border}` }}>
<div style={{ padding: '5px 14px 4px', background: p.bgAlt, borderBottom: `1px solid ${p.borderInner}`, display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '2px', height: '10px', background: p.accent, borderRadius: '1px', flexShrink: 0 }} />
<span style={{ fontSize: '10px', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: p.textMuted }}>{label}</span>
</div>
{children}
</div>
)
const StatCell: React.FC<WithPalette & { label: string; value: string; accent: string }> = ({ p, label, value, accent }) => (
<div style={{ background: p.bg, padding: '8px 14px' }}>
<div style={{ fontSize: '10px', color: p.textDim, textTransform: 'uppercase' as const, letterSpacing: '0.06em', marginBottom: '2px' }}>{label}</div>
<div style={{ fontSize: '15px', fontWeight: 600, color: accent }}>{value}</div>
</div>
)
const Row: React.FC<WithPalette & { label: string; value: string; mono?: boolean; accent?: string }> = ({ p, label, value, mono, accent }) => (
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '8px', padding: '4px 14px', borderBottom: `1px solid ${p.borderInner}`, alignItems: 'baseline' }}>
<span style={{ fontSize: '11px', color: p.textDim, flexShrink: 0 }}>{label}</span>
<span style={{ fontSize: '12px', color: accent || p.text, fontFamily: mono ? 'monospace' : 'inherit', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value}
</span>
</div>
)
function maybeRelative(value?: string): string {
if (!value || value.startsWith('0001-')) return '—'
return formatRelativeTime(value)
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
const amount = value / (1024 ** unit)
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`
}
export default LocalAgentApp

View file

@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import LocalAgentApp from './LocalAgentApp'
import '../index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LocalAgentApp />
</React.StrictMode>,
)

View file

@ -1,35 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Tauri's embedded asset server resolves index.html from frontendDist; the
// desktop entry is index.desktop.html, so rename it in the bundle output.
const desktopEntryAsIndex = {
name: 'desktop-entry-as-index',
generateBundle(_options: unknown, bundle: Record<string, { fileName: string }>) {
const entry = bundle['index.desktop.html']
if (entry) {
entry.fileName = 'index.html'
}
},
}
export default defineConfig({
plugins: [react(), desktopEntryAsIndex],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist-desktop",
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, "index.desktop.html"),
},
},
server: {
port: 3001,
strictPort: true,
},
})