Files
wmantly 4a619f7adc feat(windows): platform ops, service wrapper, helper, and air-gap paths
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).

- Add a PlatformOps abstraction so command dispatch is OS-neutral:
  - linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
    untagged so shared dispatch tests run on Windows CI)
  - windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
    sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
    powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
    configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
    SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
    user32/wtsapi32, and staged self-update (wait for service stop, swap the
    locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
    Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
    service runs as SYSTEM while the tray runs as the user, so the per-user temp
    dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
    external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
    POSIX so the suite is green on Windows

Rebuilds all tracked dist binaries (v2.1.0).
2026-08-09 17:10:07 -07:00

87 lines
3.1 KiB
Go

package main
// IPC protocol between the root daemon (theta-agent) and the desktop tray
// (theta-agent-tray). Both processes communicate via a Unix domain socket at
// /run/theta/tray.sock (created by the daemon; the tray connects to it).
//
// Protocol: newline-delimited JSON. The daemon streams TrayStatus messages to
// any connected tray client. The tray sends TrayCommand messages to the daemon.
import (
"encoding/json"
"runtime"
)
// TraySocketPaths are the paths the daemon tries to bind, in order.
//
// Windows has no /run or /tmp. The daemon runs as the SYSTEM service while the
// tray runs as the logged-in user, so the socket lives in the shared data dir
// (%ProgramData%\Theta42, created by the installer with a Users-writable ACL)
// rather than a per-user temp dir — the two processes must agree on one path.
// Linux keeps the original /run/theta path with a /tmp fallback.
var TraySocketPaths = func() []string {
if runtime.GOOS == "windows" {
return []string{windowsTraySocketPath()}
}
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
}()
// TraySocket is the canonical socket path the tray dials. It matches the last
// entry in TraySocketPaths on each platform.
var TraySocket = func() string {
if runtime.GOOS == "windows" {
return windowsTraySocketPath()
}
return "/tmp/theta-tray.sock"
}()
// TrayColor represents the icon color state.
type TrayColor string
const (
ColorRed TrayColor = "red" // not connected to directory
ColorYellow TrayColor = "yellow" // connected, but not home
ColorGreen TrayColor = "green" // connected, on home LAN (public IP matches)
ColorBlue TrayColor = "blue" // connected + WireGuard tunnel active to home site
)
// TrayStatus is sent from the daemon to the tray on every state change.
type TrayStatus struct {
Color TrayColor `json:"color"`
Connected bool `json:"connected"` // directory WebSocket is up
IsHome bool `json:"is_home"` // public IP matches home site
VPNActive bool `json:"vpn_active"` // WireGuard tunnel is up
AutoVPN bool `json:"auto_vpn"` // auto-connect preference
SiteName string `json:"site_name"` // configured site name
AgentPublicIP string `json:"agent_public_ip"` // this agent's detected public IP
HomePublicIP string `json:"home_public_ip"` // home site's public IP (from directory)
StatusText string `json:"status_text"` // one-line human description
}
// TrayCommand is sent from the tray to the daemon.
type TrayCommand struct {
Command string `json:"command"` // "set_auto_vpn", "vpn_connect", "vpn_disconnect"
Value bool `json:"value"` // used by set_auto_vpn
}
func encodeTrayStatus(s TrayStatus) ([]byte, error) {
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
func decodeTrayCommand(data []byte) (TrayCommand, error) {
var cmd TrayCommand
err := json.Unmarshal(data, &cmd)
return cmd, err
}
func decodeTrayStatus(data []byte) (TrayStatus, error) {
var s TrayStatus
err := json.Unmarshal(data, &s)
return s, err
}