Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ examples/go_agent_nodes/multi_version
# Compiled binaries
control-plane/agentfield-server
control-plane/af
control-plane/af-tray
scripts/lambda-rie-fake/lambda-rie-fake

# Python
Expand Down
41 changes: 40 additions & 1 deletion .goreleaser.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added control-plane/cmd/af-tray/assets/appicon.icns
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions control-plane/cmd/af-tray/assets_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package main

import _ "embed"

// Menu-bar icons. These are embedded on every platform (they are just bytes),
// but only referenced by the darwin tray implementation. Replace these with the
// final brand assets when available — icon_active is shown when the control
// plane is healthy, icon_inactive when it is stopped/unreachable.
//
//go:embed assets/icon_active.png
var iconActive []byte

//go:embed assets/icon_inactive.png
var iconInactive []byte

// appIconICNS is written into the generated .app bundle's Resources on macOS.
//
//go:embed assets/appicon.icns
var appIconICNS []byte
131 changes: 131 additions & 0 deletions control-plane/cmd/af-tray/launchd_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//go:build darwin

package main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
)

// ---- Install / uninstall ---------------------------------------------------

// installDesktop is idempotent and convergent: every run rewrites the .app
// bundle and both launchd plists, then bootstraps-or-force-restarts each agent.
// This is what makes `curl … | install.sh` hands-off on both a fresh install
// and an update — a stale, already-running tray is killed and relaunched onto
// the freshly installed binary, and a freshly written agent is started now
// (not just at next login).
func installDesktop() error {
for _, d := range []string{logsDir(), launchAgentsDir(),
filepath.Join(appBundleDir(), "Contents", "MacOS"),
filepath.Join(appBundleDir(), "Contents", "Resources")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", d, err)
}
}

// Build the .app bundle around a copy of ourselves. Using rename-over means
// we can safely replace the binary even while an old tray is executing it.
self, err := os.Executable()
if err != nil {
return fmt.Errorf("locate self: %w", err)
}
selfData, err := os.ReadFile(self)
if err != nil {
return fmt.Errorf("read self: %w", err)
}
if err := writeFileAtomic(trayBundleBinaryPath(), selfData, 0o755); err != nil {
return fmt.Errorf("install tray binary: %w", err)
}
if err := writeFileAtomic(filepath.Join(appBundleDir(), "Contents", "Resources", "appicon.icns"), appIconICNS, 0o644); err != nil {
return fmt.Errorf("write app icon: %w", err)
}
if err := writeFileAtomic(filepath.Join(appBundleDir(), "Contents", "Info.plist"), []byte(infoPlist()), 0o644); err != nil {
return fmt.Errorf("write Info.plist: %w", err)
}

// launchd agents.
if err := writeFileAtomic(serverPlistPath(), []byte(serverPlist()), 0o644); err != nil {
return fmt.Errorf("write server plist: %w", err)
}
if err := writeFileAtomic(trayPlistPath(), []byte(trayPlist()), 0o644); err != nil {
return fmt.Errorf("write tray plist: %w", err)
}

// Converge launchd state. bootstrap is ignored if already loaded; the
// kickstart -k then force-restarts onto the new binary in every case.
_ = bootstrapAgent(serverPlistPath())
_ = kickstartAgent(serverLabel, true)
_ = bootstrapAgent(trayPlistPath())
_ = kickstartAgent(trayLabel, true)

fmt.Println("AgentField desktop tray installed. Look for the icon in your menu bar.")
return nil
}

func uninstallDesktop() error {
_ = bootoutAgent(trayLabel)
_ = bootoutAgent(serverLabel)
_ = os.Remove(trayPlistPath())
_ = os.Remove(serverPlistPath())
_ = os.RemoveAll(appBundleDir())
fmt.Println("AgentField desktop tray removed.")
return nil
}

// ---- Server lifecycle (driven from the tray menu) --------------------------

func startServer() error {
if !agentLoaded(serverLabel) {
_ = bootstrapAgent(serverPlistPath())
}
return kickstartAgent(serverLabel, false)
}

// stopServer sends SIGTERM for a graceful shutdown. Because the server plist
// uses KeepAlive={SuccessfulExit: false}, a clean exit is not relaunched — so
// "Stop" actually stops it, while a genuine crash still auto-restarts.
func stopServer() error {
return exec.Command("launchctl", "kill", "SIGTERM", svcTarget(serverLabel)).Run()
}

func restartServer() error {
if !agentLoaded(serverLabel) {
_ = bootstrapAgent(serverPlistPath())
}
return kickstartAgent(serverLabel, true)
}

// serverAutostartEnabled reflects whether the server agent is loaded (and will
// therefore start at login).
func serverAutostartEnabled() bool { return agentLoaded(serverLabel) }

func setServerAutostart(enable bool) error {
if enable {
if err := bootstrapAgent(serverPlistPath()); err != nil && !agentLoaded(serverLabel) {
return err
}
return kickstartAgent(serverLabel, false)
}
return bootoutAgent(serverLabel)
}

// ---- launchctl exec wrappers -----------------------------------------------

func bootstrapAgent(plistPath string) error {
return exec.Command("launchctl", "bootstrap", guiDomain(), plistPath).Run()
}

func bootoutAgent(label string) error {
return exec.Command("launchctl", "bootout", svcTarget(label)).Run()
}

func kickstartAgent(label string, kill bool) error {
return exec.Command("launchctl", kickstartArgs(label, kill)...).Run()
}

func agentLoaded(label string) bool {
return exec.Command("launchctl", "print", svcTarget(label)).Run() == nil
}
80 changes: 80 additions & 0 deletions control-plane/cmd/af-tray/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Command af-tray is the AgentField menu-bar companion.
//
// It is a small, separate binary from the main `af`/agentfield control-plane
// binary on purpose: it carries the GUI/systray dependency so the server binary
// never has to. In a headless deployment (Railway, ECS, EC2, a container) the
// tray simply is never installed or run — and if it is run there anyway, it
// detects the absence of a GUI session and exits cleanly instead of crashing.
//
// Subcommands:
//
// af-tray run Run the menu-bar tray (default).
// af-tray install Install the desktop tray + control-plane autostart (macOS).
// af-tray uninstall Remove the desktop tray and autostart.
// af-tray version Print version.
//
// The platform-specific behaviour lives in tray_darwin.go / launchd_darwin.go
// (real implementation) and tray_other.go (no-op stubs for every other OS).
package main

import (
"fmt"
"os"
)

// Build-time version information (set via ldflags during build).
var (
version = "dev"
commit = "none"
date = "unknown"
)

func main() {
os.Exit(run(os.Args[1:]))
}

// run dispatches a subcommand and returns the process exit code. It is split
// out of main so it can be unit-tested without spawning a process.
func run(args []string) int {
cmd := "run"
if len(args) > 0 {
cmd = args[0]
}

switch cmd {
case "run":
if err := runTray(); err != nil {
fmt.Fprintln(os.Stderr, "af-tray:", err)
return 1
}
case "install":
if err := installDesktop(); err != nil {
fmt.Fprintln(os.Stderr, "af-tray install:", err)
return 1
}
case "uninstall":
if err := uninstallDesktop(); err != nil {
fmt.Fprintln(os.Stderr, "af-tray uninstall:", err)
return 1
}
case "version", "--version", "-v":
fmt.Printf("af-tray %s (%s) %s\n", version, commit, date)
case "help", "--help", "-h":
printUsage()
default:
printUsage()
return 2
}
return 0
}

func printUsage() {
fmt.Print(`af-tray — AgentField menu-bar companion

Usage:
af-tray run Run the menu-bar tray (default)
af-tray install Install the desktop tray + control-plane autostart (macOS)
af-tray uninstall Remove the desktop tray and control-plane autostart
af-tray version Print version information
`)
}
18 changes: 18 additions & 0 deletions control-plane/cmd/af-tray/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import "testing"

// Contract: the CLI dispatch returns 0 for version/help variants and 2 for an
// unknown subcommand. These arms are platform-independent (they don't touch the
// tray or launchd), so they run everywhere.
func TestRunDispatchExitCodes(t *testing.T) {
zero := []string{"version", "--version", "-v", "help", "--help", "-h"}
for _, cmd := range zero {
if code := run([]string{cmd}); code != 0 {
t.Errorf("run([%q]) = %d, want 0", cmd, code)
}
}
if code := run([]string{"totally-unknown"}); code != 2 {
t.Errorf("run([unknown]) = %d, want 2", code)
}
}
Loading
Loading