Files
hockey_new/agent/core.go

1374 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.5.0"
protocolVersion = 1
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
heartbeatInterval = 8 * time.Second
overlayPollInterval = 400 * time.Millisecond
overlayResendInterval = 2 * 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"`
Overlays []vmixOverlayXML `xml:"overlays>overlay"`
}
type vmixOverlayXML struct {
Number string `xml:"number,attr"`
Key string `xml:"key,attr"`
Value string `xml:",chardata"`
}
type VmixOverlayTally struct {
Input string `json:"input"`
InputNumber string `json:"input_number,omitempty"`
InputKey string `json:"input_key,omitempty"`
InputTitle string `json:"input_title,omitempty"`
}
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.vmixOverlayMonitor()
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 (a *Agent) vmixOverlayMonitor() {
ticker := time.NewTicker(overlayPollInterval)
defer ticker.Stop()
lastHash := ""
var lastWS *wsConn
lastSentAt := time.Time{}
for {
a.mu.RLock()
vmixURL := a.cfg.VmixURL
ws := a.currentWS
a.mu.RUnlock()
overlays, connected := scanVmixOverlayTally(vmixURL)
encoded, _ := json.Marshal(overlays)
stateHash := fmt.Sprintf("%t:%s", connected, string(encoded))
force := ws != nil && ws != lastWS
periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval)
changed := stateHash != lastHash
if ws != nil && (force || periodic || changed) {
payload := map[string]any{
"type": "vmix.overlay_state",
"device_id": a.cfg.DeviceID,
"vmix_connected": connected,
"overlays": overlays,
"observed_at": time.Now().UTC().Format(time.RFC3339Nano),
}
if err := ws.WriteJSON(payload); err == nil {
lastHash = stateHash
lastWS = ws
lastSentAt = time.Now()
}
}
if ws == nil {
lastWS = nil
}
select {
case <-a.stop:
return
case <-ticker.C:
}
}
}
func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) {
result := map[string]VmixOverlayTally{}
target := strings.TrimSpace(base)
if target == "" {
return result, false
}
resp, err := vmixHTTPClient.Get(target)
if err != nil {
return result, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return result, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
if err != nil {
return result, false
}
var api vmixAPIXML
if err := xml.Unmarshal(body, &api); err != nil {
return result, true
}
byNumber := map[string]vmixInputXML{}
byKey := map[string]vmixInputXML{}
for _, input := range api.Inputs {
number := strings.TrimSpace(input.Number)
key := strings.TrimSpace(input.Key)
if number != "" {
byNumber[number] = input
}
if key != "" {
byKey[strings.ToLower(key)] = input
}
}
for _, overlay := range api.Overlays {
layer := strings.TrimSpace(overlay.Number)
if layer != "1" && layer != "2" && layer != "3" && layer != "4" {
continue
}
raw := strings.TrimSpace(overlay.Value)
rawKey := strings.TrimSpace(overlay.Key)
if raw == "" && rawKey == "" {
continue
}
var matched vmixInputXML
found := false
if raw != "" {
if item, ok := byNumber[raw]; ok {
matched, found = item, true
} else if item, ok := byKey[strings.ToLower(raw)]; ok {
matched, found = item, true
}
}
if !found && rawKey != "" {
if item, ok := byKey[strings.ToLower(rawKey)]; ok {
matched, found = item, true
}
}
entry := VmixOverlayTally{Input: raw}
if entry.Input == "" {
entry.Input = rawKey
}
if found {
entry.InputNumber = strings.TrimSpace(matched.Number)
entry.InputKey = strings.TrimSpace(matched.Key)
entry.InputTitle = strings.TrimSpace(matched.Title)
if entry.Input == "" {
entry.Input = entry.InputNumber
}
} else {
entry.InputKey = rawKey
if _, ok := byNumber[raw]; ok || raw != "" {
entry.InputNumber = raw
}
}
result[layer] = entry
}
return result, true
}
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 }