1233 lines
34 KiB
Go
1233 lines
34 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"crypto/rand"
|
||
"crypto/sha1"
|
||
"crypto/sha256"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"encoding/xml"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
agentVersion = "1.4.0"
|
||
protocolVersion = 1
|
||
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||
heartbeatInterval = 8 * time.Second
|
||
)
|
||
|
||
type Config struct {
|
||
ServerURL string `json:"server_url"`
|
||
FallbackServerURL string `json:"fallback_server_url,omitempty"`
|
||
VmixURL string `json:"vmix_url"`
|
||
DeviceID string `json:"device_id"`
|
||
DeviceSecret string `json:"device_secret"`
|
||
DeviceName string `json:"device_name"`
|
||
StartMinimized bool `json:"start_minimized"`
|
||
RunWithWindows bool `json:"run_with_windows"`
|
||
}
|
||
|
||
type VmixStatus struct {
|
||
Connected bool `json:"connected"`
|
||
Version string `json:"version,omitempty"`
|
||
URL string `json:"url"`
|
||
Error string `json:"error,omitempty"`
|
||
Response string `json:"response,omitempty"`
|
||
}
|
||
|
||
type VmixFieldInventory struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Index string `json:"index,omitempty"`
|
||
}
|
||
|
||
type VmixInputInventory struct {
|
||
Key string `json:"key,omitempty"`
|
||
Number string `json:"number,omitempty"`
|
||
Title string `json:"title"`
|
||
Type string `json:"type,omitempty"`
|
||
Fields []VmixFieldInventory `json:"fields"`
|
||
}
|
||
|
||
type VmixInventory struct {
|
||
Fingerprint string `json:"fingerprint"`
|
||
Inputs []VmixInputInventory `json:"inputs"`
|
||
InputCount int `json:"input_count"`
|
||
FieldCount int `json:"field_count"`
|
||
VmixVersion string `json:"vmix_version,omitempty"`
|
||
}
|
||
|
||
type vmixAPIXML struct {
|
||
Version string `xml:"version"`
|
||
Inputs []vmixInputXML `xml:"inputs>input"`
|
||
}
|
||
|
||
type vmixInputXML struct {
|
||
Key string `xml:"key,attr"`
|
||
Number string `xml:"number,attr"`
|
||
Type string `xml:"type,attr"`
|
||
Title string `xml:"title,attr"`
|
||
Children []vmixFieldXML `xml:",any"`
|
||
}
|
||
|
||
type vmixFieldXML struct {
|
||
XMLName xml.Name
|
||
Index string `xml:"index,attr"`
|
||
Name string `xml:"name,attr"`
|
||
}
|
||
|
||
type AppState struct {
|
||
ServerConnected bool
|
||
ServerConnecting bool
|
||
ServerError string
|
||
Vmix VmixStatus
|
||
Paired bool
|
||
PairedLogin string
|
||
Active bool
|
||
AssignmentID string
|
||
MatchID string
|
||
MappingName string
|
||
MappingVersion string
|
||
LastCommand string
|
||
LastCommandOK bool
|
||
LastCommandAt time.Time
|
||
LastCommandDetail string
|
||
LastServerAt time.Time
|
||
ConnectedServer string
|
||
}
|
||
|
||
type Agent struct {
|
||
mu sync.RWMutex
|
||
cfg Config
|
||
cfgPath string
|
||
state AppState
|
||
logs []string
|
||
logFilePath string
|
||
fileLog chan string
|
||
currentWS *wsConn
|
||
inventory VmixInventory
|
||
stop chan struct{}
|
||
reconnect chan struct{}
|
||
}
|
||
|
||
type wsConn struct {
|
||
conn net.Conn
|
||
r *bufio.Reader
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func NewAgent(cfgPath string, cfg Config) *Agent {
|
||
return &Agent{
|
||
cfgPath: cfgPath,
|
||
cfg: cfg,
|
||
stop: make(chan struct{}),
|
||
reconnect: make(chan struct{}, 1),
|
||
logs: make([]string, 0, 150),
|
||
logFilePath: filepath.Join(filepath.Dir(cfgPath), "agent.log"),
|
||
fileLog: make(chan string, 256),
|
||
state: AppState{Vmix: VmixStatus{URL: cfg.VmixURL}},
|
||
}
|
||
}
|
||
|
||
func (a *Agent) Start() {
|
||
go a.fileLogLoop()
|
||
a.logf("Agent %s started", agentVersion)
|
||
a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
|
||
go a.vmixMonitor()
|
||
go a.connectionLoop()
|
||
}
|
||
|
||
func (a *Agent) Stop() {
|
||
select {
|
||
case <-a.stop:
|
||
return
|
||
default:
|
||
close(a.stop)
|
||
}
|
||
a.mu.Lock()
|
||
ws := a.currentWS
|
||
a.currentWS = nil
|
||
a.mu.Unlock()
|
||
// Never perform socket I/O while holding the Agent mutex. A slow or stuck
|
||
// network close must not block GUI reads such as Config()/Snapshot().
|
||
if ws != nil {
|
||
_ = ws.Close()
|
||
}
|
||
}
|
||
|
||
func (a *Agent) Config() Config {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return a.cfg
|
||
}
|
||
|
||
func (a *Agent) UpdateConfig(cfg Config) error {
|
||
a.mu.Lock()
|
||
// Device identity is intentionally immutable from the GUI.
|
||
cfg.DeviceID = a.cfg.DeviceID
|
||
cfg.DeviceSecret = a.cfg.DeviceSecret
|
||
if strings.TrimSpace(cfg.DeviceName) == "" {
|
||
cfg.DeviceName = hostname()
|
||
}
|
||
cfg.ServerURL = normalizeServerBase(cfg.ServerURL)
|
||
cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL)
|
||
cfg.VmixURL = strings.TrimSpace(cfg.VmixURL)
|
||
a.cfg = cfg
|
||
a.mu.Unlock()
|
||
if err := saveConfig(a.cfgPath, cfg); err != nil {
|
||
return err
|
||
}
|
||
a.logf("Settings saved")
|
||
select {
|
||
case a.reconnect <- struct{}{}:
|
||
default:
|
||
}
|
||
a.mu.RLock()
|
||
ws := a.currentWS
|
||
a.mu.RUnlock()
|
||
// Closing a WebSocket can occasionally take longer than expected on a bad
|
||
// network. Do it outside the mutex so the Windows UI never waits on it.
|
||
if ws != nil {
|
||
_ = ws.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Agent) Snapshot() AppState {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return a.state
|
||
}
|
||
|
||
func (a *Agent) Logs() string {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return strings.Join(a.logs, "\r\n")
|
||
}
|
||
|
||
func (a *Agent) logf(format string, args ...any) {
|
||
line := fmt.Sprintf("[%s] %s", stamp(), fmt.Sprintf(format, args...))
|
||
a.mu.Lock()
|
||
a.logs = append(a.logs, line)
|
||
if len(a.logs) > 150 {
|
||
a.logs = append([]string(nil), a.logs[len(a.logs)-150:]...)
|
||
}
|
||
a.mu.Unlock()
|
||
// File logging is best-effort and never allowed to block the Agent. This is
|
||
// especially useful when the GUI itself becomes unresponsive: agent.log can
|
||
// still show the last network/vMix event before the problem.
|
||
select {
|
||
case a.fileLog <- line:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func (a *Agent) fileLogLoop() {
|
||
path := a.logFilePath
|
||
if stat, err := os.Stat(path); err == nil && stat.Size() > 2*1024*1024 {
|
||
_ = os.Remove(path + ".1")
|
||
_ = os.Rename(path, path+".1")
|
||
}
|
||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
for {
|
||
select {
|
||
case <-a.stop:
|
||
return
|
||
case line := <-a.fileLog:
|
||
_, _ = io.WriteString(f, time.Now().Format("2006-01-02 ")+line+"\r\n")
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *Agent) vmixMonitor() {
|
||
ticker := time.NewTicker(3 * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
a.mu.RLock()
|
||
vmixURL := a.cfg.VmixURL
|
||
a.mu.RUnlock()
|
||
status, inventory := scanVmix(vmixURL)
|
||
a.mu.Lock()
|
||
previous := a.state.Vmix.Connected
|
||
previousErr := a.state.Vmix.Error
|
||
previousFingerprint := a.inventory.Fingerprint
|
||
a.state.Vmix = status
|
||
a.inventory = inventory
|
||
ws := a.currentWS
|
||
a.mu.Unlock()
|
||
if status.Connected && inventory.Fingerprint != "" && inventory.Fingerprint != previousFingerprint && ws != nil {
|
||
if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": a.cfg.DeviceID, "inventory": inventory, "vmix": status}); err == nil {
|
||
a.logf("vMix structure sent: %d Inputs / %d fields", inventory.InputCount, inventory.FieldCount)
|
||
}
|
||
}
|
||
if status.Connected && !previous {
|
||
a.logf("vMix connected%s", suffixVersion(status.Version))
|
||
}
|
||
if !status.Connected && (previous || status.Error != previousErr) {
|
||
a.logf("vMix not found: %s", status.Error)
|
||
}
|
||
select {
|
||
case <-a.stop:
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
|
||
func suffixVersion(v string) string {
|
||
if v == "" {
|
||
return ""
|
||
}
|
||
return " · " + v
|
||
}
|
||
|
||
func (a *Agent) connectionLoop() {
|
||
backoff := time.Second
|
||
outer:
|
||
for {
|
||
select {
|
||
case <-a.stop:
|
||
return
|
||
default:
|
||
}
|
||
a.mu.RLock()
|
||
cfg := a.cfg
|
||
a.mu.RUnlock()
|
||
candidates := serverCandidates(cfg)
|
||
if len(candidates) == 0 {
|
||
candidates = []string{"http://127.0.0.1:8000"}
|
||
}
|
||
var lastErr error
|
||
for _, serverBase := range candidates {
|
||
a.mu.Lock()
|
||
a.state.ServerConnecting = true
|
||
a.state.ServerError = ""
|
||
a.mu.Unlock()
|
||
connectionStarted := time.Now()
|
||
err := a.runConnection(serverBase)
|
||
if time.Since(connectionStarted) >= 5*time.Second {
|
||
backoff = time.Second
|
||
}
|
||
a.mu.Lock()
|
||
a.state.ServerConnected = false
|
||
a.state.ServerConnecting = false
|
||
a.state.ConnectedServer = ""
|
||
if err != nil {
|
||
a.state.ServerError = err.Error()
|
||
}
|
||
a.currentWS = nil
|
||
a.mu.Unlock()
|
||
if err == nil {
|
||
lastErr = nil
|
||
break
|
||
}
|
||
lastErr = err
|
||
a.logf("Server unavailable (%s): %v", serverBase, err)
|
||
select {
|
||
case <-a.stop:
|
||
return
|
||
case <-a.reconnect:
|
||
backoff = time.Second
|
||
continue outer
|
||
default:
|
||
}
|
||
}
|
||
if lastErr != nil {
|
||
a.logf("All configured Hockey servers are offline")
|
||
}
|
||
timer := time.NewTimer(backoff)
|
||
select {
|
||
case <-a.stop:
|
||
timer.Stop()
|
||
return
|
||
case <-a.reconnect:
|
||
timer.Stop()
|
||
backoff = time.Second
|
||
continue
|
||
case <-timer.C:
|
||
}
|
||
if backoff < 15*time.Second {
|
||
backoff *= 2
|
||
}
|
||
if backoff > 15*time.Second {
|
||
backoff = 15 * time.Second
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *Agent) runConnection(serverBase string) error {
|
||
a.mu.RLock()
|
||
cfg := a.cfg
|
||
a.mu.RUnlock()
|
||
target, err := websocketURL(serverBase)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
a.logf("Connecting to %s", target)
|
||
ws, err := dialWebSocket(target, 7*time.Second)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer ws.Close()
|
||
a.mu.Lock()
|
||
a.currentWS = ws
|
||
a.state.ServerConnected = true
|
||
a.state.ServerConnecting = false
|
||
a.state.ServerError = ""
|
||
a.state.LastServerAt = time.Now()
|
||
a.state.ConnectedServer = serverBase
|
||
a.mu.Unlock()
|
||
a.logf("Server connected: %s", serverBase)
|
||
|
||
vmix, inventory := scanVmix(cfg.VmixURL)
|
||
a.mu.Lock()
|
||
a.state.Vmix = vmix
|
||
a.inventory = inventory
|
||
a.mu.Unlock()
|
||
hello := map[string]any{
|
||
"type": "hello", "protocol": protocolVersion,
|
||
"device_id": cfg.DeviceID, "device_secret": cfg.DeviceSecret,
|
||
"device_name": cfg.DeviceName, "hostname": hostname(),
|
||
"agent_version": agentVersion, "platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||
"vmix": vmix,
|
||
}
|
||
if err := ws.WriteJSON(hello); err != nil {
|
||
return err
|
||
}
|
||
if inventory.Fingerprint != "" {
|
||
if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": cfg.DeviceID, "inventory": inventory, "vmix": vmix}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Heartbeats are written from their own goroutine. The old implementation
|
||
// used a 1.2 s read timeout merely to get a chance to send heartbeats. If a
|
||
// WebSocket frame arrived slowly, that timeout could fire after part of the
|
||
// frame had already been consumed, desynchronising the stream. From then on
|
||
// the agent could randomly disconnect or appear to hang. A dedicated writer
|
||
// removes that failure mode completely.
|
||
heartbeatStop := make(chan struct{})
|
||
heartbeatErr := make(chan error, 1)
|
||
defer close(heartbeatStop)
|
||
|
||
sendHeartbeat := func() error {
|
||
a.mu.RLock()
|
||
currentVmix := a.state.Vmix
|
||
assignmentID, matchID := a.state.AssignmentID, a.state.MatchID
|
||
a.mu.RUnlock()
|
||
return ws.WriteJSON(map[string]any{
|
||
"type": "heartbeat", "device_id": cfg.DeviceID,
|
||
"assignment_id": assignmentID, "match_id": matchID,
|
||
"vmix": currentVmix, "error": currentVmix.Error,
|
||
})
|
||
}
|
||
|
||
// Send one immediately so the server sees fresh status without waiting.
|
||
if err := sendHeartbeat(); err != nil {
|
||
return err
|
||
}
|
||
go func() {
|
||
ticker := time.NewTicker(heartbeatInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-heartbeatStop:
|
||
return
|
||
case <-a.stop:
|
||
return
|
||
case <-ticker.C:
|
||
if err := sendHeartbeat(); err != nil {
|
||
select {
|
||
case heartbeatErr <- err:
|
||
default:
|
||
}
|
||
_ = ws.Close() // unblock the reader immediately
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// The server answers every heartbeat. If no complete server frame is
|
||
// received for 30 seconds, treat the TCP connection as stale and reconnect.
|
||
// A timeout always discards this socket; we never continue after partially
|
||
// consuming a frame.
|
||
for {
|
||
_ = ws.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||
payload, err := ws.ReadText()
|
||
if err != nil {
|
||
select {
|
||
case hbErr := <-heartbeatErr:
|
||
return fmt.Errorf("heartbeat write failed: %w", hbErr)
|
||
default:
|
||
}
|
||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||
return errors.New("server watchdog timeout: no data for 30s")
|
||
}
|
||
select {
|
||
case <-a.stop:
|
||
return nil
|
||
default:
|
||
}
|
||
return err
|
||
}
|
||
var msg map[string]any
|
||
if json.Unmarshal(payload, &msg) != nil {
|
||
continue
|
||
}
|
||
a.mu.Lock()
|
||
a.state.LastServerAt = time.Now()
|
||
a.mu.Unlock()
|
||
if err := a.handleMessage(ws, msg); err != nil {
|
||
a.logf("Message error: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *Agent) handleMessage(ws *wsConn, msg map[string]any) error {
|
||
typ := stringValue(msg["type"])
|
||
switch typ {
|
||
case "hello.ok":
|
||
a.logf("Device registered on server")
|
||
if dev, ok := msg["device"].(map[string]any); ok {
|
||
a.mu.Lock()
|
||
a.state.Paired = boolValue(dev["paired"])
|
||
a.state.PairedLogin = stringValue(dev["owner"])
|
||
a.state.Active = boolValue(dev["active_for_account"])
|
||
a.state.MatchID = stringValue(dev["current_match_id"])
|
||
a.state.AssignmentID = stringValue(dev["assignment_id"])
|
||
a.mu.Unlock()
|
||
}
|
||
case "hello.error":
|
||
return errors.New(stringValue(msg["detail"]))
|
||
case "pairing.confirmed":
|
||
account, _ := msg["account"].(map[string]any)
|
||
login := stringValue(account["login"])
|
||
active := boolValue(msg["active_for_account"])
|
||
a.mu.Lock()
|
||
a.state.Paired = true
|
||
a.state.PairedLogin = login
|
||
a.state.Active = active
|
||
a.mu.Unlock()
|
||
a.logf("Paired to account: %s", login)
|
||
case "pairing.revoked":
|
||
a.mu.Lock()
|
||
a.state.Paired = false
|
||
a.state.PairedLogin = ""
|
||
a.state.Active = false
|
||
a.state.AssignmentID = ""
|
||
a.state.MatchID = ""
|
||
a.mu.Unlock()
|
||
a.logf("Pairing revoked")
|
||
case "device.activated":
|
||
a.mu.Lock()
|
||
a.state.Active = true
|
||
a.mu.Unlock()
|
||
a.logf("Device activated for broadcast")
|
||
case "device.deactivated":
|
||
a.mu.Lock()
|
||
a.state.Active = false
|
||
a.state.AssignmentID = ""
|
||
a.state.MatchID = ""
|
||
a.mu.Unlock()
|
||
a.logf("Device moved to standby")
|
||
case "match.assign":
|
||
assignmentID := stringValue(msg["assignment_id"])
|
||
matchID := stringValue(msg["game_id"])
|
||
a.mu.Lock()
|
||
old := a.state.MatchID
|
||
a.state.AssignmentID = assignmentID
|
||
a.state.MatchID = matchID
|
||
a.mu.Unlock()
|
||
if old != "" && old != matchID {
|
||
a.logf("Match switched: %s → %s", old, matchID)
|
||
} else {
|
||
a.logf("Match assigned: %s", matchID)
|
||
}
|
||
a.mu.RLock()
|
||
vmix := a.state.Vmix
|
||
a.mu.RUnlock()
|
||
return ws.WriteJSON(map[string]any{"type": "match.accepted", "device_id": a.cfg.DeviceID, "assignment_id": assignmentID, "match_id": matchID, "vmix": vmix})
|
||
case "vmix.probe":
|
||
a.mu.RLock()
|
||
vmix := a.state.Vmix
|
||
a.mu.RUnlock()
|
||
return ws.WriteJSON(map[string]any{"type": "vmix.status", "request_id": stringValue(msg["request_id"]), "vmix": vmix, "error": vmix.Error})
|
||
case "vmix.command":
|
||
return a.handleVmixCommand(ws, msg)
|
||
case "vmix.batch":
|
||
return a.handleVmixBatch(ws, msg)
|
||
case "heartbeat.ack":
|
||
return nil
|
||
case "mapping.assigned":
|
||
a.mu.Lock()
|
||
a.state.MappingName = stringValue(msg["name"])
|
||
a.state.MappingVersion = stringValue(msg["version"])
|
||
a.mu.Unlock()
|
||
a.logf("Mapping assigned: %s", stringValue(msg["name"]))
|
||
case "mapping.missing":
|
||
a.mu.Lock()
|
||
a.state.MappingName = ""
|
||
a.state.MappingVersion = ""
|
||
a.mu.Unlock()
|
||
a.logf("Mapping not configured for current vMix project")
|
||
case "server.error":
|
||
a.logf("Server error: %s", stringValue(msg["detail"]))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Agent) handleVmixCommand(ws *wsConn, msg map[string]any) error {
|
||
requestID := stringValue(msg["request_id"])
|
||
incomingAssignment := stringValue(msg["assignment_id"])
|
||
incomingMatch := stringValue(msg["match_id"])
|
||
a.mu.RLock()
|
||
currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID
|
||
vmixURL := a.cfg.VmixURL
|
||
a.mu.RUnlock()
|
||
if incomingAssignment != "" && incomingAssignment != currentAssignment {
|
||
return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch})
|
||
}
|
||
if incomingMatch != "" && incomingMatch != currentMatch {
|
||
return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch})
|
||
}
|
||
rawCommand, _ := msg["command"].(map[string]any)
|
||
function := stringValue(rawCommand["Function"])
|
||
if function == "" {
|
||
function = stringValue(rawCommand["function"])
|
||
}
|
||
if function == "" {
|
||
return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "missing_function"})
|
||
}
|
||
params := url.Values{}
|
||
params.Set("Function", function)
|
||
for key, value := range rawCommand {
|
||
if strings.EqualFold(key, "function") || value == nil {
|
||
continue
|
||
}
|
||
text := stringValue(value)
|
||
if strings.EqualFold(key, "Value") || text != "" {
|
||
params.Set(key, text)
|
||
}
|
||
}
|
||
detail := function
|
||
if input := params.Get("Input"); input != "" {
|
||
detail += " · " + input
|
||
}
|
||
if field := params.Get("SelectedName"); field != "" {
|
||
detail += " · " + field
|
||
}
|
||
a.logf("vMix command: %s", detail)
|
||
vmix := probeVmix(vmixURL, params)
|
||
a.mu.Lock()
|
||
a.state.Vmix = vmix
|
||
a.state.LastCommand = detail
|
||
a.state.LastCommandOK = vmix.Connected
|
||
a.state.LastCommandAt = time.Now()
|
||
a.state.LastCommandDetail = vmix.Error
|
||
a.mu.Unlock()
|
||
if vmix.Connected {
|
||
a.logf("Command OK: %s", function)
|
||
} else {
|
||
a.logf("Command failed: %s", vmix.Error)
|
||
}
|
||
return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": vmix.Connected, "reason": vmix.Error, "assignment_id": currentAssignment, "match_id": currentMatch, "vmix": vmix, "error": vmix.Error})
|
||
}
|
||
|
||
func (a *Agent) handleVmixBatch(ws *wsConn, msg map[string]any) error {
|
||
requestID := stringValue(msg["request_id"])
|
||
incomingAssignment := stringValue(msg["assignment_id"])
|
||
incomingMatch := stringValue(msg["match_id"])
|
||
a.mu.RLock()
|
||
currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID
|
||
vmixURL := a.cfg.VmixURL
|
||
a.mu.RUnlock()
|
||
if incomingAssignment != "" && incomingAssignment != currentAssignment {
|
||
return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
|
||
}
|
||
if incomingMatch != "" && incomingMatch != currentMatch {
|
||
return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
|
||
}
|
||
rawCommands, _ := msg["commands"].([]any)
|
||
if len(rawCommands) == 0 {
|
||
return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": true, "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}, "applied": 0})
|
||
}
|
||
if len(rawCommands) > 500 {
|
||
return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "too_many_commands", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
|
||
}
|
||
|
||
type batchResult struct {
|
||
OK bool
|
||
Reason string
|
||
Detail string
|
||
Function string
|
||
Vmix VmixStatus
|
||
}
|
||
results := make([]batchResult, len(rawCommands))
|
||
jobs := make(chan int)
|
||
workers := 6
|
||
if len(rawCommands) < workers {
|
||
workers = len(rawCommands)
|
||
}
|
||
var wg sync.WaitGroup
|
||
wg.Add(workers)
|
||
for w := 0; w < workers; w++ {
|
||
go func() {
|
||
defer wg.Done()
|
||
for index := range jobs {
|
||
rawCommand, _ := rawCommands[index].(map[string]any)
|
||
function := stringValue(rawCommand["Function"])
|
||
if function == "" {
|
||
function = stringValue(rawCommand["function"])
|
||
}
|
||
if function == "" {
|
||
results[index] = batchResult{OK: false, Reason: "missing_function"}
|
||
continue
|
||
}
|
||
params := url.Values{}
|
||
params.Set("Function", function)
|
||
for key, value := range rawCommand {
|
||
if strings.EqualFold(key, "function") || value == nil {
|
||
continue
|
||
}
|
||
text := stringValue(value)
|
||
if strings.EqualFold(key, "Value") || text != "" {
|
||
params.Set(key, text)
|
||
}
|
||
}
|
||
detail := function
|
||
if input := params.Get("Input"); input != "" {
|
||
detail += " · " + input
|
||
}
|
||
if field := params.Get("SelectedName"); field != "" {
|
||
detail += " · " + field
|
||
}
|
||
vmix := probeVmix(vmixURL, params)
|
||
results[index] = batchResult{OK: vmix.Connected, Reason: vmix.Error, Detail: detail, Function: function, Vmix: vmix}
|
||
}
|
||
}()
|
||
}
|
||
for index := range rawCommands {
|
||
jobs <- index
|
||
}
|
||
close(jobs)
|
||
wg.Wait()
|
||
|
||
ackResults := make([]map[string]any, len(results))
|
||
applied := 0
|
||
failed := 0
|
||
lastDetail := ""
|
||
lastStatus := VmixStatus{URL: vmixURL}
|
||
for index, item := range results {
|
||
ackResults[index] = map[string]any{"ok": item.OK, "reason": item.Reason}
|
||
if item.OK {
|
||
applied++
|
||
} else {
|
||
failed++
|
||
}
|
||
if item.Detail != "" {
|
||
lastDetail = item.Detail
|
||
lastStatus = item.Vmix
|
||
}
|
||
}
|
||
a.mu.Lock()
|
||
if lastDetail != "" {
|
||
a.state.Vmix = lastStatus
|
||
a.state.LastCommand = fmt.Sprintf("Batch %d · %s", len(results), lastDetail)
|
||
a.state.LastCommandOK = failed == 0
|
||
a.state.LastCommandAt = time.Now()
|
||
if failed > 0 {
|
||
a.state.LastCommandDetail = fmt.Sprintf("%d команд с ошибкой", failed)
|
||
} else {
|
||
a.state.LastCommandDetail = ""
|
||
}
|
||
}
|
||
a.mu.Unlock()
|
||
a.logf("vMix batch: %d commands · OK %d · errors %d", len(results), applied, failed)
|
||
return ws.WriteJSON(map[string]any{
|
||
"type": "command.batch.ack", "request_id": requestID, "ok": failed == 0,
|
||
"assignment_id": currentAssignment, "match_id": currentMatch,
|
||
"applied": applied, "failed": failed, "results": ackResults,
|
||
})
|
||
}
|
||
|
||
func scanVmix(base string) (VmixStatus, VmixInventory) {
|
||
status := VmixStatus{URL: base}
|
||
inventory := VmixInventory{Inputs: []VmixInputInventory{}}
|
||
target := strings.TrimSpace(base)
|
||
if target == "" {
|
||
status.Error = "vMix URL is empty"
|
||
return status, inventory
|
||
}
|
||
client := &http.Client{Timeout: 4 * time.Second}
|
||
resp, err := client.Get(target)
|
||
if err != nil {
|
||
status.Error = err.Error()
|
||
return status, inventory
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
status.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||
return status, inventory
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
|
||
if err != nil {
|
||
status.Error = err.Error()
|
||
return status, inventory
|
||
}
|
||
status.Connected = true
|
||
var api vmixAPIXML
|
||
if err := xml.Unmarshal(body, &api); err != nil {
|
||
status.Error = "vMix XML parse: " + err.Error()
|
||
status.Connected = true
|
||
return status, inventory
|
||
}
|
||
status.Version = strings.TrimSpace(api.Version)
|
||
inventory.VmixVersion = status.Version
|
||
fingerprintLines := make([]string, 0, len(api.Inputs))
|
||
for _, raw := range api.Inputs {
|
||
item := VmixInputInventory{
|
||
Key: strings.TrimSpace(raw.Key), Number: strings.TrimSpace(raw.Number),
|
||
Title: strings.TrimSpace(raw.Title), Type: strings.TrimSpace(raw.Type),
|
||
Fields: []VmixFieldInventory{},
|
||
}
|
||
fieldLines := make([]string, 0, len(raw.Children))
|
||
for _, child := range raw.Children {
|
||
name := strings.TrimSpace(child.Name)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
fieldType := strings.ToLower(strings.TrimSpace(child.XMLName.Local))
|
||
if fieldType == "" {
|
||
fieldType = "field"
|
||
}
|
||
item.Fields = append(item.Fields, VmixFieldInventory{Name: name, Type: fieldType, Index: strings.TrimSpace(child.Index)})
|
||
fieldLines = append(fieldLines, fieldType+":"+name)
|
||
}
|
||
sort.Slice(item.Fields, func(i, j int) bool {
|
||
if item.Fields[i].Type == item.Fields[j].Type {
|
||
return item.Fields[i].Name < item.Fields[j].Name
|
||
}
|
||
return item.Fields[i].Type < item.Fields[j].Type
|
||
})
|
||
sort.Strings(fieldLines)
|
||
inventory.FieldCount += len(item.Fields)
|
||
inventory.Inputs = append(inventory.Inputs, item)
|
||
fingerprintLines = append(fingerprintLines, item.Type+"|"+item.Title+"|"+strings.Join(fieldLines, ","))
|
||
}
|
||
sort.Slice(inventory.Inputs, func(i, j int) bool {
|
||
if inventory.Inputs[i].Title == inventory.Inputs[j].Title {
|
||
return inventory.Inputs[i].Number < inventory.Inputs[j].Number
|
||
}
|
||
return inventory.Inputs[i].Title < inventory.Inputs[j].Title
|
||
})
|
||
sort.Strings(fingerprintLines)
|
||
inventory.InputCount = len(inventory.Inputs)
|
||
hash := sha256.Sum256([]byte(strings.Join(fingerprintLines, "\n")))
|
||
inventory.Fingerprint = fmt.Sprintf("%x", hash[:])
|
||
return status, inventory
|
||
}
|
||
|
||
var vmixHTTPClient = &http.Client{
|
||
Timeout: 3 * time.Second,
|
||
Transport: &http.Transport{
|
||
MaxIdleConns: 32,
|
||
MaxIdleConnsPerHost: 16,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
},
|
||
}
|
||
|
||
func probeVmix(base string, params url.Values) VmixStatus {
|
||
result := VmixStatus{URL: base}
|
||
target := strings.TrimSpace(base)
|
||
if target == "" {
|
||
result.Error = "vMix URL is empty"
|
||
return result
|
||
}
|
||
if params != nil && len(params) > 0 {
|
||
sep := "?"
|
||
if strings.Contains(target, "?") {
|
||
sep = "&"
|
||
}
|
||
target += sep + params.Encode()
|
||
}
|
||
resp, err := vmixHTTPClient.Get(target)
|
||
if err != nil {
|
||
result.Error = err.Error()
|
||
return result
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||
return result
|
||
}
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||
result.Connected = true
|
||
text := string(body)
|
||
if params != nil && len(params) > 0 {
|
||
responseText := strings.TrimSpace(text)
|
||
if len(responseText) > 1000 {
|
||
responseText = responseText[:1000]
|
||
}
|
||
result.Response = responseText
|
||
}
|
||
if i := strings.Index(text, "<version>"); i >= 0 {
|
||
rest := text[i+len("<version>"):]
|
||
if j := strings.Index(rest, "</version>"); j >= 0 {
|
||
result.Version = strings.TrimSpace(rest[:j])
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func normalizeServerBase(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
if !strings.Contains(raw, "://") {
|
||
raw = "http://" + raw
|
||
}
|
||
return strings.TrimRight(raw, "/")
|
||
}
|
||
|
||
// serverCandidates makes deployment independent from localhost.
|
||
// Priority:
|
||
// 1. HOCKEY_SERVER_URL environment variable
|
||
// 2. server_url.txt next to agent.exe (handy for server migration/deployment)
|
||
// 3. primary URL saved in agent_config.json
|
||
// 4. optional fallback URL saved in agent_config.json
|
||
func serverCandidates(cfg Config) []string {
|
||
values := make([]string, 0, 4)
|
||
if env := normalizeServerBase(os.Getenv("HOCKEY_SERVER_URL")); env != "" {
|
||
values = append(values, env)
|
||
}
|
||
if exe, err := os.Executable(); err == nil {
|
||
path := filepath.Join(filepath.Dir(exe), "server_url.txt")
|
||
if data, err := os.ReadFile(path); err == nil {
|
||
if v := normalizeServerBase(strings.TrimSpace(string(data))); v != "" {
|
||
values = append(values, v)
|
||
}
|
||
}
|
||
}
|
||
values = append(values, normalizeServerBase(cfg.ServerURL), normalizeServerBase(cfg.FallbackServerURL))
|
||
seen := map[string]bool{}
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if value == "" || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
out = append(out, value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func websocketURL(server string) (string, error) {
|
||
raw := strings.TrimRight(strings.TrimSpace(server), "/")
|
||
if raw == "" {
|
||
return "", errors.New("server_url is empty")
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
switch strings.ToLower(u.Scheme) {
|
||
case "http":
|
||
u.Scheme = "ws"
|
||
case "https":
|
||
u.Scheme = "wss"
|
||
case "ws", "wss":
|
||
default:
|
||
if !strings.Contains(raw, "://") {
|
||
return websocketURL("http://" + raw)
|
||
}
|
||
return "", fmt.Errorf("unsupported server scheme: %s", u.Scheme)
|
||
}
|
||
u.Path = strings.TrimRight(u.Path, "/") + "/ws/hockey-agent"
|
||
u.RawQuery = ""
|
||
return u.String(), nil
|
||
}
|
||
|
||
func dialWebSocket(target string, timeout time.Duration) (*wsConn, error) {
|
||
u, err := url.Parse(target)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
host := u.Hostname()
|
||
port := u.Port()
|
||
if port == "" {
|
||
if u.Scheme == "wss" {
|
||
port = "443"
|
||
} else {
|
||
port = "80"
|
||
}
|
||
}
|
||
address := net.JoinHostPort(host, port)
|
||
d := net.Dialer{Timeout: timeout}
|
||
var conn net.Conn
|
||
if u.Scheme == "wss" {
|
||
conn, err = tls.DialWithDialer(&d, "tcp", address, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||
} else {
|
||
conn, err = d.Dial("tcp", address)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Bound the entire HTTP Upgrade handshake. Without this, a peer that
|
||
// accepts TCP but never completes the response can stall a connection attempt.
|
||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||
key := randomBase64(16)
|
||
path := u.EscapedPath()
|
||
if path == "" {
|
||
path = "/"
|
||
}
|
||
if u.RawQuery != "" {
|
||
path += "?" + u.RawQuery
|
||
}
|
||
req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nUser-Agent: Hockey-vMix-Agent/%s\r\n\r\n", path, u.Host, key, agentVersion)
|
||
if _, err := io.WriteString(conn, req); err != nil {
|
||
conn.Close()
|
||
return nil, err
|
||
}
|
||
reader := bufio.NewReader(conn)
|
||
statusLine, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, err
|
||
}
|
||
if !strings.Contains(statusLine, " 101 ") {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("websocket upgrade failed: %s", strings.TrimSpace(statusLine))
|
||
}
|
||
headers := map[string]string{}
|
||
for {
|
||
line, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, err
|
||
}
|
||
line = strings.TrimRight(line, "\r\n")
|
||
if line == "" {
|
||
break
|
||
}
|
||
if i := strings.Index(line, ":"); i > 0 {
|
||
headers[strings.ToLower(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:])
|
||
}
|
||
}
|
||
sum := sha1.Sum([]byte(key + websocketGUID))
|
||
expected := base64.StdEncoding.EncodeToString(sum[:])
|
||
if headers["sec-websocket-accept"] != expected {
|
||
conn.Close()
|
||
return nil, errors.New("invalid Sec-WebSocket-Accept")
|
||
}
|
||
_ = conn.SetDeadline(time.Time{})
|
||
return &wsConn{conn: conn, r: reader}, nil
|
||
}
|
||
func (w *wsConn) Close() error { return w.conn.Close() }
|
||
func (w *wsConn) WriteJSON(value any) error {
|
||
payload, err := json.Marshal(value)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return w.writeFrame(0x1, payload)
|
||
}
|
||
func (w *wsConn) writeFrame(opcode byte, payload []byte) error {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
// Never let a dead network block the WebSocket writer forever.
|
||
_ = w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||
defer w.conn.SetWriteDeadline(time.Time{})
|
||
var header bytes.Buffer
|
||
header.WriteByte(0x80 | opcode)
|
||
n := len(payload)
|
||
switch {
|
||
case n < 126:
|
||
header.WriteByte(0x80 | byte(n))
|
||
case n <= 65535:
|
||
header.WriteByte(0x80 | 126)
|
||
_ = binary.Write(&header, binary.BigEndian, uint16(n))
|
||
default:
|
||
header.WriteByte(0x80 | 127)
|
||
_ = binary.Write(&header, binary.BigEndian, uint64(n))
|
||
}
|
||
mask := make([]byte, 4)
|
||
if _, err := rand.Read(mask); err != nil {
|
||
return err
|
||
}
|
||
header.Write(mask)
|
||
masked := make([]byte, n)
|
||
for i := range payload {
|
||
masked[i] = payload[i] ^ mask[i%4]
|
||
}
|
||
if _, err := w.conn.Write(header.Bytes()); err != nil {
|
||
return err
|
||
}
|
||
_, err := w.conn.Write(masked)
|
||
return err
|
||
}
|
||
func (w *wsConn) ReadText() ([]byte, error) {
|
||
for {
|
||
first, err := w.r.ReadByte()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
second, err := w.r.ReadByte()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fin := first&0x80 != 0
|
||
opcode := first & 0x0F
|
||
masked := second&0x80 != 0
|
||
length := uint64(second & 0x7F)
|
||
if length == 126 {
|
||
var n uint16
|
||
if err := binary.Read(w.r, binary.BigEndian, &n); err != nil {
|
||
return nil, err
|
||
}
|
||
length = uint64(n)
|
||
} else if length == 127 {
|
||
if err := binary.Read(w.r, binary.BigEndian, &length); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if length > 16*1024*1024 {
|
||
return nil, errors.New("websocket frame too large")
|
||
}
|
||
var mask [4]byte
|
||
if masked {
|
||
if _, err := io.ReadFull(w.r, mask[:]); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
payload := make([]byte, int(length))
|
||
if _, err := io.ReadFull(w.r, payload); err != nil {
|
||
return nil, err
|
||
}
|
||
if masked {
|
||
for i := range payload {
|
||
payload[i] ^= mask[i%4]
|
||
}
|
||
}
|
||
switch opcode {
|
||
case 0x1:
|
||
if !fin {
|
||
return nil, errors.New("fragmented text frames are not supported")
|
||
}
|
||
return payload, nil
|
||
case 0x8:
|
||
return nil, io.EOF
|
||
case 0x9:
|
||
_ = w.writeFrame(0xA, payload)
|
||
case 0xA:
|
||
continue
|
||
default:
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
func configPath() (string, error) {
|
||
exe, err := os.Executable()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Join(filepath.Dir(exe), "agent_config.json"), nil
|
||
}
|
||
func loadConfig(path string) (Config, error) {
|
||
cfg := Config{ServerURL: "http://127.0.0.1:8000", VmixURL: "http://127.0.0.1:8088/api/", DeviceID: newUUID(), DeviceSecret: randomToken(40), DeviceName: hostname()}
|
||
if data, err := os.ReadFile(path); err == nil {
|
||
_ = json.Unmarshal(data, &cfg)
|
||
}
|
||
if cfg.DeviceID == "" {
|
||
cfg.DeviceID = newUUID()
|
||
}
|
||
if cfg.DeviceSecret == "" {
|
||
cfg.DeviceSecret = randomToken(40)
|
||
}
|
||
if cfg.DeviceName == "" {
|
||
cfg.DeviceName = hostname()
|
||
}
|
||
if cfg.ServerURL == "" {
|
||
cfg.ServerURL = "http://127.0.0.1:8000"
|
||
}
|
||
cfg.ServerURL = normalizeServerBase(cfg.ServerURL)
|
||
cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL)
|
||
if cfg.VmixURL == "" {
|
||
cfg.VmixURL = "http://127.0.0.1:8088/api/"
|
||
}
|
||
if err := saveConfig(path, cfg); err != nil {
|
||
return cfg, err
|
||
}
|
||
return cfg, nil
|
||
}
|
||
func saveConfig(path string, cfg Config) error {
|
||
data, err := json.MarshalIndent(cfg, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(path, data, 0600)
|
||
}
|
||
func newUUID() string {
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
b[6] = (b[6] & 0x0f) | 0x40
|
||
b[8] = (b[8] & 0x3f) | 0x80
|
||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||
}
|
||
func randomBase64(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
return base64.StdEncoding.EncodeToString(b)
|
||
}
|
||
func randomToken(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
return base64.RawURLEncoding.EncodeToString(b)
|
||
}
|
||
func hostname() string {
|
||
h, _ := os.Hostname()
|
||
if h == "" {
|
||
return "HOCKEY-GFX"
|
||
}
|
||
return h
|
||
}
|
||
func stamp() string { return time.Now().Format("15:04:05") }
|
||
func short(v string) string {
|
||
if len(v) > 12 {
|
||
return v[:12] + "..."
|
||
}
|
||
return v
|
||
}
|
||
func stringValue(v any) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return fmt.Sprint(v)
|
||
}
|
||
func boolValue(v any) bool { b, _ := v.(bool); return b }
|