Files
alice-backend/station/cmd/pc-audio-agent/main_windows.go
T

210 lines
5.7 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strconv"
"time"
"unsafe"
"alice-back/station/pcaudio"
"golang.org/x/sys/windows"
)
const agentLease = 5 * time.Second
type sender struct {
process windows.Handle
job windows.Handle
}
func startSender(path, host string, port uint16) (*sender, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
var limits windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
windows.CloseHandle(job)
return nil, err
}
program, _ := windows.UTF16PtrFromString(path)
args, _ := windows.UTF16PtrFromString(windows.ComposeCommandLine([]string{path, host, strconv.Itoa(int(port))}))
dir, _ := windows.UTF16PtrFromString(filepath.Dir(path))
info := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
var child windows.ProcessInformation
if err := windows.CreateProcess(program, args, nil, nil, false, windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, dir, &info, &child); err != nil {
windows.CloseHandle(job)
return nil, err
}
defer windows.CloseHandle(child.Thread)
err = windows.AssignProcessToJobObject(job, child.Process)
if err == nil {
_, err = windows.ResumeThread(child.Thread)
}
if err != nil {
_ = windows.TerminateProcess(child.Process, 1)
windows.CloseHandle(child.Process)
windows.CloseHandle(job)
return nil, err
}
return &sender{process: child.Process, job: job}, nil
}
func (s *sender) stop() {
windows.CloseHandle(s.job)
_, _ = windows.WaitForSingleObject(s.process, 1000)
windows.CloseHandle(s.process)
}
func (s *sender) exited() bool {
status, err := windows.WaitForSingleObject(s.process, 0)
return err != nil || status != uint32(windows.WAIT_TIMEOUT)
}
func poll(client *http.Client, url, token string, state pcaudio.State) (pcaudio.Command, error) {
data, _ := json.Marshal(state)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return pcaudio.Command{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return pcaudio.Command{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return pcaudio.Command{}, fmt.Errorf("station returned HTTP %d", resp.StatusCode)
}
var command pcaudio.Command
err = json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&command)
if err == nil && len(command.Revision) != 32 {
err = errors.New("invalid station command")
}
return command, err
}
func run() error {
exe, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exe)
path := filepath.Join(dir, "ScreamSender.exe")
if _, err := os.Stat(path); err != nil {
return err
}
data, err := os.ReadFile(filepath.Join(dir, "agent.json"))
if err != nil {
return err
}
var config struct {
Station string
Port uint16
Token string
}
if err := json.Unmarshal(data, &config); err != nil {
return err
}
host, err := netip.ParseAddrPort(config.Station)
if err != nil || !host.Addr().Is4() || config.Port == 0 || len(config.Token) != 64 {
return errors.New("invalid agent configuration")
}
name, _ := windows.UTF16PtrFromString("Local\\AlicePCAudio")
mutex, err := windows.CreateMutex(nil, false, name)
if mutex != 0 {
defer windows.CloseHandle(mutex)
}
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
return nil
}
if err != nil {
return err
}
client := &http.Client{
Timeout: 900 * time.Millisecond,
Transport: &http.Transport{DialContext: (&net.Dialer{}).DialContext},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
url := "http://" + host.String() + pcaudio.Path
var state pcaudio.State
var child *sender
var lastSuccess time.Time
var previousError string
for {
if child != nil && child.exited() {
child.stop()
child = nil
state.Enabled = false
state.Error = "sender_exited"
log.Print("Scream Sender exited")
}
command, err := poll(client, url, config.Token, state)
now := time.Now()
if err != nil {
if err.Error() != previousError {
log.Printf("Station: %v", err)
previousError = err.Error()
}
if child != nil && now.Sub(lastSuccess) >= agentLease {
child.stop()
child = nil
state = pcaudio.State{}
log.Print("Station connection lost. Streaming stopped")
}
} else {
if lastSuccess.IsZero() || previousError != "" {
log.Print("Connected to Mini 2")
}
lastSuccess, previousError = now, ""
if command.Revision != state.Revision {
// Stop any sender from the previous command before applying a new one.
if child != nil && !command.Enabled {
child.stop()
child = nil
log.Print("Streaming stopped")
}
state = pcaudio.State{Command: command}
if command.Enabled && child == nil {
child, err = startSender(path, host.Addr().String(), config.Port)
if err != nil {
state.Enabled = false
state.Error = "sender_start_failed"
log.Printf("Scream Sender: %v", err)
} else {
log.Print("Streaming started")
}
}
}
}
time.Sleep(650 * time.Millisecond)
}
}
func main() {
if exe, err := os.Executable(); err == nil {
path := filepath.Join(filepath.Dir(exe), "agent.log")
if logs, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600); err == nil {
defer logs.Close()
log.SetOutput(logs)
}
}
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}