major improvements

This commit is contained in:
KingKDot
2026-06-12 09:14:49 -05:00
parent a2634a7454
commit b6672ee375
47 changed files with 1498 additions and 231 deletions
+2 -2
View File
@@ -41,11 +41,11 @@ jobs:
- name: Build BackstageInjection DLL with MSVC - name: Build BackstageInjection DLL with MSVC
shell: cmd shell: cmd
run: call build-backstage-dll.bat run: call scripts\build-backstage-dll.bat
- name: Build BackstageCapture DLL with MSVC - name: Build BackstageCapture DLL with MSVC
shell: cmd shell: cmd
run: call build-backstage-capture-dll.bat run: call scripts\build-backstage-capture-dll.bat
- name: Upload HVNC DLLs - name: Upload HVNC DLLs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
-1
View File
@@ -81,7 +81,6 @@ release/
stress/ stress/
Overlord-Client/cmd/loadgen/ Overlord-Client/cmd/loadgen/
start-prod-tuned.bat
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
+6 -6
View File
@@ -88,24 +88,24 @@ COPY Overlord-Server/ ./
# HVNC sources for the cross-compile fallback (used only if no pre-built MSVC DLL). # HVNC sources for the cross-compile fallback (used only if no pre-built MSVC DLL).
COPY BackstageInjection/ ./BackstageInjection/ COPY BackstageInjection/ ./BackstageInjection/
COPY build-backstage-dll.sh ./build-backstage-dll.sh COPY scripts/build-backstage-dll.sh ./scripts/
COPY BackstageCapture/ ./BackstageCapture/ COPY BackstageCapture/ ./BackstageCapture/
COPY build-backstage-capture-dll.sh ./build-backstage-capture-dll.sh COPY scripts/build-backstage-capture-dll.sh ./scripts/
RUN mkdir -p dist-clients && \ RUN mkdir -p dist-clients && \
if [ -f dist-clients/BackstageInjection.x64.dll ]; then \ if [ -f dist-clients/BackstageInjection.x64.dll ]; then \
echo "Using pre-built MSVC BackstageInjection DLL"; \ echo "Using pre-built MSVC BackstageInjection DLL"; \
else \ else \
chmod +x build-backstage-dll.sh && \ chmod +x scripts/build-backstage-dll.sh && \
HVNC_SRC_DIR=BackstageInjection/src HVNC_OUT_DIR=dist-clients bash build-backstage-dll.sh || \ HVNC_SRC_DIR=BackstageInjection/src HVNC_OUT_DIR=dist-clients bash scripts/build-backstage-dll.sh || \
echo "WARNING: BackstageInjection DLL not available (build with MSVC on Windows)"; \ echo "WARNING: BackstageInjection DLL not available (build with MSVC on Windows)"; \
fi fi
RUN if [ -f dist-clients/BackstageCapture.x64.dll ]; then \ RUN if [ -f dist-clients/BackstageCapture.x64.dll ]; then \
echo "Using pre-built MSVC BackstageCapture DLL"; \ echo "Using pre-built MSVC BackstageCapture DLL"; \
else \ else \
chmod +x build-backstage-capture-dll.sh && \ chmod +x scripts/build-backstage-capture-dll.sh && \
HVNC_CAPTURE_SRC_DIR=BackstageCapture/src HVNC_CAPTURE_OUT_DIR=dist-clients bash build-backstage-capture-dll.sh || \ HVNC_CAPTURE_SRC_DIR=BackstageCapture/src HVNC_CAPTURE_OUT_DIR=dist-clients bash scripts/build-backstage-capture-dll.sh || \
echo "WARNING: BackstageCapture DLL not available (build with MSVC on Windows)"; \ echo "WARNING: BackstageCapture DLL not available (build with MSVC on Windows)"; \
fi fi
@@ -1105,6 +1105,8 @@ var (
uiaDragStartPt point uiaDragStartPt point
uiaLastClickTime time.Time uiaLastClickTime time.Time
uiaLastClickHwnd uintptr uiaLastClickHwnd uintptr
uiaLastClickBtn int
uiaLastClickPt point
) )
func uiaSetActiveElement(elem *iuiAutomationElement) { func uiaSetActiveElement(elem *iuiAutomationElement) {
@@ -1164,37 +1166,24 @@ func uiaHandleMouseMove(hwnd uintptr, screenPt point) {
func uiaHandleMouseButton(hwnd uintptr, screenPt point, button int, down bool) error { func uiaHandleMouseButton(hwnd uintptr, screenPt point, button int, down bool) error {
uiaEnsureWorker() uiaEnsureWorker()
inputSite := findInputSiteChild(hwnd)
msg, wparam, ok := uiaMouseButtonMessage(button, down)
if !ok {
return nil
}
messageTarget := uiaFastMouseTarget(hwnd, inputSite)
if down { if down {
uiaActiveMu.Lock() uiaActiveMu.Lock()
uiaDragStartPt = screenPt uiaDragStartPt = screenPt
uiaDragActive = false uiaDragActive = false
uiaActiveMu.Unlock() uiaActiveMu.Unlock()
inputSite := findInputSiteChild(hwnd) uiaPostMouseToTarget(messageTarget, msg, wparam, screenPt)
if inputSite != 0 {
clientPt := screenPt
procScreenToClient.Call(inputSite, uintptr(unsafe.Pointer(&clientPt)))
switch button {
case 0:
postMouseMessage(inputSite, WM_LBUTTONDOWN, MK_LBUTTON, clientPt)
case 2:
postMouseMessage(inputSite, WM_RBUTTONDOWN, MK_RBUTTON, clientPt)
}
}
return nil return nil
} }
inputSite := findInputSiteChild(hwnd) uiaPostMouseToTarget(messageTarget, msg, wparam, screenPt)
if inputSite != 0 {
clientPt := screenPt
procScreenToClient.Call(inputSite, uintptr(unsafe.Pointer(&clientPt)))
switch button {
case 0:
postMouseMessage(inputSite, WM_LBUTTONUP, 0, clientPt)
case 2:
postMouseMessage(inputSite, WM_RBUTTONUP, 0, clientPt)
}
}
uiaActiveMu.Lock() uiaActiveMu.Lock()
wasInDrag := uiaDragActive wasInDrag := uiaDragActive
@@ -1206,22 +1195,70 @@ func uiaHandleMouseButton(hwnd uintptr, screenPt point, button int, down bool) e
} }
now := time.Now() now := time.Now()
isDoubleClick := now.Sub(uiaLastClickTime) < 400*time.Millisecond && uiaLastClickHwnd == hwnd dx := screenPt.x - uiaLastClickPt.x
dy := screenPt.y - uiaLastClickPt.y
isDoubleClick := now.Sub(uiaLastClickTime) < 400*time.Millisecond &&
uiaLastClickHwnd == hwnd &&
uiaLastClickBtn == button &&
dx*dx+dy*dy <= 25
uiaLastClickTime = now uiaLastClickTime = now
uiaLastClickHwnd = hwnd uiaLastClickHwnd = hwnd
uiaLastClickBtn = button
uiaLastClickPt = screenPt
pt := screenPt // copy for closure pt := screenPt // copy for closure
btn := button btn := button
isDbl := isDoubleClick isDbl := isDoubleClick
is := inputSite is := inputSite
h := hwnd h := hwnd
target := messageTarget
uiaRunAsync(func() { uiaRunAsync(func() {
uiaMouseUpWorker(h, pt, btn, isDbl, is) uiaMouseUpWorker(h, pt, btn, isDbl, is, target)
}) })
return nil return nil
} }
func uiaMouseUpWorker(hwnd uintptr, screenPt point, button int, isDoubleClick bool, inputSite uintptr) { func uiaMouseButtonMessage(button int, down bool) (uint32, uintptr, bool) {
wparam := uintptr(currentMouseButtons())
switch button {
case 0:
if down {
return WM_LBUTTONDOWN, wparam | MK_LBUTTON, true
}
return WM_LBUTTONUP, wparam, true
case 1:
if down {
return WM_MBUTTONDOWN, wparam | MK_MBUTTON, true
}
return WM_MBUTTONUP, wparam, true
case 2:
if down {
return WM_RBUTTONDOWN, wparam | MK_RBUTTON, true
}
return WM_RBUTTONUP, wparam, true
default:
return 0, 0, false
}
}
func uiaFastMouseTarget(hwnd uintptr, inputSite uintptr) uintptr {
if inputSite != 0 {
return inputSite
}
return hwnd
}
func uiaPostMouseToTarget(hwnd uintptr, msg uint32, wparam uintptr, screenPt point) bool {
if hwnd == 0 {
return false
}
clientPt := screenPt
procScreenToClient.Call(hwnd, uintptr(unsafe.Pointer(&clientPt)))
postMouseMessage(hwnd, msg, wparam, clientPt)
return true
}
func uiaMouseUpWorker(hwnd uintptr, screenPt point, button int, isDoubleClick bool, inputSite uintptr, postedTarget uintptr) {
if uiaSingleton == nil { if uiaSingleton == nil {
return return
} }
@@ -1235,42 +1272,109 @@ func uiaMouseUpWorker(hwnd uintptr, screenPt point, button int, isDoubleClick bo
} }
defer elem.Release() defer elem.Release()
messageTarget := uiaResolvedMouseTarget(hwnd, elem, inputSite)
if messageTarget == 0 {
messageTarget = postedTarget
}
switch button { switch button {
case 0: // Left click case 0: // Left click
if uiaTryAction(elem, isDoubleClick) { if isWinUI3Window(hwnd) {
return if uiaTryActionAscending(elem, isDoubleClick) {
} return
if uiaTreeWalker != nil {
current := elem
for i := 0; i < 6; i++ {
parent := uiaTreeWalker.GetParentElement(current)
if parent == nil {
break
}
if current != elem {
current.Release()
}
current = parent
if uiaTryAction(current, isDoubleClick) {
if current != elem {
current.Release()
}
return
}
}
if current != elem {
current.Release()
} }
} }
elem.SetFocus() elem.SetFocus()
uiaSelectItem(elem)
case 2: // Right click — context menu case 2: // Right click: identify the element, then ask its window for a context menu.
if inputSite != 0 { elem.SetFocus()
procPostMessageW.Call(inputSite, WM_CONTEXTMENU, inputSite, makeLParam(screenPt.x, screenPt.y)) uiaSelectItem(elem)
if messageTarget != 0 {
procPostMessageW.Call(messageTarget, WM_CONTEXTMENU, messageTarget, makeLParam(screenPt.x, screenPt.y))
} }
} }
} }
func uiaResolvedMouseTarget(hwnd uintptr, elem *iuiAutomationElement, inputSite uintptr) uintptr {
if inputSite != 0 && isWinUI3Window(hwnd) {
return inputSite
}
if native := uiaNativeWindowFromElement(elem); native != 0 {
return native
}
if inputSite != 0 {
return inputSite
}
return hwnd
}
func uiaNativeWindowFromElement(elem *iuiAutomationElement) uintptr {
if elem == nil {
return 0
}
if hwnd := elem.GetNativeWindowHandle(); hwnd != 0 {
return hwnd
}
if uiaTreeWalker == nil {
return 0
}
current := elem
for i := 0; i < 8; i++ {
parent := uiaTreeWalker.GetParentElement(current)
if parent == nil {
break
}
if current != elem {
current.Release()
}
current = parent
if hwnd := current.GetNativeWindowHandle(); hwnd != 0 {
if current != elem {
current.Release()
}
return hwnd
}
}
if current != elem {
current.Release()
}
return 0
}
func uiaTryActionAscending(elem *iuiAutomationElement, isDoubleClick bool) bool {
if elem == nil {
return false
}
if uiaTryAction(elem, isDoubleClick) {
return true
}
if uiaTreeWalker == nil {
return false
}
current := elem
for i := 0; i < 6; i++ {
parent := uiaTreeWalker.GetParentElement(current)
if parent == nil {
break
}
if current != elem {
current.Release()
}
current = parent
if uiaTryAction(current, isDoubleClick) {
if current != elem {
current.Release()
}
return true
}
}
if current != elem {
current.Release()
}
return false
}
func uiaTryAction(elem *iuiAutomationElement, isDoubleClick bool) bool { func uiaTryAction(elem *iuiAutomationElement, isDoubleClick bool) bool {
if uiaInvoke(elem) { if uiaInvoke(elem) {
if isDoubleClick { if isDoubleClick {
@@ -1122,8 +1122,9 @@ func hvncMouseButtonOnThread(button int, down bool) error {
return nil return nil
} }
// WinUI3 branch: route through UIA patterns // UIA branch: keep message-style clicks as the primary signal, but let
if hvncUIAEnabled.Load() && isWinUI3Window(hitHwnd) { // UIA resolve complicated targets such as WinUI3/Explorer elements.
if hvncUIAEnabled.Load() {
return uiaHandleMouseButton(hitHwnd, pt, button, down) return uiaHandleMouseButton(hitHwnd, pt, button, down)
} }
@@ -614,6 +614,14 @@ func ResetPrev() {
resetH264Encoder() resetH264Encoder()
} }
func ResetPrevHVNC() {
hvncPrevMu.Lock()
hvncPrevFrame = nil
hvncPrevMu.Unlock()
hvncLastKeyframe.Store(0)
resetH264EncoderHVNC()
}
func jpegQuality() int { func jpegQuality() int {
if q := overrideQuality.Load(); q > 0 { if q := overrideQuality.Load(); q > 0 {
@@ -1550,12 +1558,14 @@ func SetQualityAndCodec(quality int, codec string) {
h264WarnOnce = sync.Once{} h264WarnOnce = sync.Once{}
if s != "h264" { if s != "h264" {
resetH264Encoder() resetH264Encoder()
resetH264EncoderHVNC()
} }
case "": case "":
default: default:
overrideCodec.Store("jpeg") overrideCodec.Store("jpeg")
resetH264Encoder() resetH264Encoder()
resetH264EncoderHVNC()
} }
} }
@@ -1599,9 +1609,19 @@ func captureAndSendHVNC(ctx context.Context, env *rt.Env) error {
} }
captureDur := time.Since(t0) captureDur := time.Since(t0)
willSendViaWebRTC := blockCodec() == "h264" && webrtcpub.IsActive(webrtcpub.KindHVNC)
var slotAcquired bool
if !willSendViaWebRTC && !AcquireFrameSlot() {
return nil
}
slotAcquired = !willSendViaWebRTC
quality := jpegQuality() quality := jpegQuality()
frame, encodeDur, err := buildFrameHVNC(img, display, quality) frame, encodeDur, err := buildFrameHVNC(img, display, quality)
if err != nil { if err != nil {
if slotAcquired {
ReleaseFrameSlot()
}
return err return err
} }
@@ -1613,12 +1633,37 @@ func captureAndSendHVNC(ctx context.Context, env *rt.Env) error {
frame.Header.FPS = fps frame.Header.FPS = fps
if ctx.Err() != nil { if ctx.Err() != nil {
if slotAcquired {
ReleaseFrameSlot()
}
return nil return nil
} }
if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindHVNC) {
dur := time.Second / time.Duration(fps)
if dur <= 0 {
dur = 33 * time.Millisecond
}
if werr := webrtcpub.WriteH264(webrtcpub.KindHVNC, frame.Data, dur); werr != nil {
log.Printf("webrtc: write hvnc h264 failed: %v", werr)
}
if slotAcquired {
ReleaseFrameSlot()
}
return nil
}
if !slotAcquired {
if !AcquireFrameSlot() {
return nil
}
slotAcquired = true
}
sendStart := time.Now() sendStart := time.Now()
err = wire.WriteMsg(ctx, env.Conn, frame) err = wire.WriteMsg(ctx, env.Conn, frame)
sendDur := time.Since(sendStart) sendDur := time.Since(sendStart)
if err != nil {
ReleaseFrameSlot()
}
if shouldLogFrame(now) { if shouldLogFrame(now) {
total := time.Since(t0) total := time.Since(t0)
@@ -443,7 +443,7 @@ var (
func encodeH264Frame(img *image.RGBA) ([]byte, error) { func encodeH264Frame(img *image.RGBA) ([]byte, error) {
h264Mu.Lock() h264Mu.Lock()
defer h264Mu.Unlock() defer h264Mu.Unlock()
out, err := encodeH264FrameWithEncoder(&h264Enc, img) out, err := encodeH264FrameWithEncoder(&h264Enc, "desktop", img)
h264LastErr = err h264LastErr = err
return out, err return out, err
} }
@@ -451,16 +451,16 @@ func encodeH264Frame(img *image.RGBA) ([]byte, error) {
func encodeH264FrameHVNC(img *image.RGBA) ([]byte, error) { func encodeH264FrameHVNC(img *image.RGBA) ([]byte, error) {
hvncH264Mu.Lock() hvncH264Mu.Lock()
defer hvncH264Mu.Unlock() defer hvncH264Mu.Unlock()
return encodeH264FrameWithEncoder(&hvncH264Enc, img) return encodeH264FrameWithEncoder(&hvncH264Enc, "backstage", img)
} }
func encodeH264FrameWebcam(img *image.RGBA) ([]byte, error) { func encodeH264FrameWebcam(img *image.RGBA) ([]byte, error) {
webcamH264Mu.Lock() webcamH264Mu.Lock()
defer webcamH264Mu.Unlock() defer webcamH264Mu.Unlock()
return encodeH264FrameWithEncoder(&webcamH264Enc, img) return encodeH264FrameWithEncoder(&webcamH264Enc, "webcam", img)
} }
func encodeH264FrameWithEncoder(slot *h264FrameEncoder, img *image.RGBA) ([]byte, error) { func encodeH264FrameWithEncoder(slot *h264FrameEncoder, stream string, img *image.RGBA) ([]byte, error) {
if img == nil { if img == nil {
return nil, errors.New("nil h264 frame") return nil, errors.New("nil h264 frame")
} }
@@ -479,7 +479,7 @@ func encodeH264FrameWithEncoder(slot *h264FrameEncoder, img *image.RGBA) ([]byte
(*slot).Close() (*slot).Close()
*slot = nil *slot = nil
} }
enc, err := newWindowsH264Encoder(width, height, fps) enc, err := newWindowsH264Encoder(stream, width, height, fps)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -546,12 +546,13 @@ func closeH264Encoder(slot *h264FrameEncoder) {
} }
} }
func newWindowsH264Encoder(width, height, fps int) (h264FrameEncoder, error) { func newWindowsH264Encoder(stream string, width, height, fps int) (h264FrameEncoder, error) {
if enc, err := newNativeH264Encoder(width, height, fps); err == nil { if enc, err := newNativeH264Encoder(stream, width, height, fps); err == nil {
return enc, nil return enc, nil
} else { } else {
log.Printf("capture: native NVENC D3D11 h264 encoder unavailable for %dx%d@%dfps: %v; trying Media Foundation", width, height, fps, err) log.Printf("capture: native NVENC D3D11 h264 encoder unavailable stream=%s size=%dx%d fps=%d: %v; trying Media Foundation", stream, width, height, fps, err)
} }
log.Printf("capture: media foundation h264 fallback selected stream=%s size=%dx%d fps=%d", stream, width, height, fps)
return newMFH264Encoder(width, height, fps) return newMFH264Encoder(width, height, fps)
} }
@@ -5,7 +5,7 @@ package capture
import "fmt" import "fmt"
import "unsafe" import "unsafe"
func newNativeH264Encoder(width, height, fps int) (h264FrameEncoder, error) { func newNativeH264Encoder(stream string, width, height, fps int) (h264FrameEncoder, error) {
return nil, fmt.Errorf("native NVENC D3D11 requires cgo for %dx%d@%dfps", width, height, fps) return nil, fmt.Errorf("native NVENC D3D11 requires cgo for %dx%d@%dfps", width, height, fps)
} }
@@ -1524,7 +1524,7 @@ type nativeH264Encoder struct {
scratch []byte scratch []byte
} }
func newNativeH264Encoder(width, height, fps int) (h264FrameEncoder, error) { func newNativeH264Encoder(stream string, width, height, fps int) (h264FrameEncoder, error) {
bitrate := targetH264Bitrate(width, height, fps) bitrate := targetH264Bitrate(width, height, fps)
raw := C.nvenc_create_d3d11_encoder(C.int(width), C.int(height), C.int(fps), C.int(bitrate)) raw := C.nvenc_create_d3d11_encoder(C.int(width), C.int(height), C.int(fps), C.int(bitrate))
if raw.encoder == nil { if raw.encoder == nil {
@@ -1537,7 +1537,7 @@ func newNativeH264Encoder(width, height, fps int) (h264FrameEncoder, error) {
requestedFPS: fps, requestedFPS: fps,
fps: fps, fps: fps,
} }
log.Printf("capture: native NVENC D3D11 h264 encoder active provider=NVIDIA NVENC size=%dx%d fps=%d input=NV12(upload) bitrate=%d", width, height, fps, bitrate) log.Printf("capture: native NVENC D3D11 h264 encoder active stream=%s provider=NVIDIA NVENC size=%dx%d fps=%d input=NV12(upload) bitrate=%d", stream, width, height, fps, bitrate)
return enc, nil return enc, nil
} }
@@ -1130,6 +1130,18 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
capture.SetMaxResolution(maxH) capture.SetMaxResolution(maxH)
sendCommandResultSafe(env, cmdID, true, "") sendCommandResultSafe(env, cmdID, true, "")
return nil return nil
case "hvnc_set_fps":
payload, _ := envelope["payload"].(map[string]interface{})
fps := 120
if payload != nil {
if v, ok := payloadInt(payload, "fps"); ok {
fps = v
}
}
fps = SetHVNCTargetFPS(fps)
log.Printf("hvnc: set target fps=%d", fps)
sendCommandResultSafe(env, cmdID, true, "")
return nil
case "hvnc_set_quality": case "hvnc_set_quality":
payload, _ := envelope["payload"].(map[string]interface{}) payload, _ := envelope["payload"].(map[string]interface{})
quality := 90 quality := 90
+31 -2
View File
@@ -6,12 +6,14 @@ import (
"overlord-client/cmd/agent/capture" "overlord-client/cmd/agent/capture"
rt "overlord-client/cmd/agent/runtime" rt "overlord-client/cmd/agent/runtime"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
var ( var (
hvncPersistedDisplayValue int hvncPersistedDisplayValue int
hvncPersistedDisplayMu sync.Mutex hvncPersistedDisplayMu sync.Mutex
hvncTargetFPS atomic.Int64
) )
func persistHVNCDisplaySelection(display int) { func persistHVNCDisplaySelection(display int) {
@@ -28,9 +30,11 @@ func GetPersistedHVNCDisplay() int {
func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error { func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
interval, fps := streamInterval("OVERLORD_HVNC_MAX_FPS", 120) fps := activeHVNCTargetFPS()
interval := time.Second / time.Duration(fps)
capture.SetH264TargetFPS(fps) capture.SetH264TargetFPS(fps)
log.Printf("hvnc: starting stream (max fps %d)", fps) capture.SetFrameFlowTargetFPS(fps)
log.Printf("hvnc: starting stream (target fps %d)", fps)
if err := capture.InitializeHVNCDesktop(); err != nil { if err := capture.InitializeHVNCDesktop(); err != nil {
log.Printf("hvnc: failed to initialize hidden desktop: %v", err) log.Printf("hvnc: failed to initialize hidden desktop: %v", err)
@@ -47,6 +51,7 @@ func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
currentFPS := fps
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -54,6 +59,14 @@ func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error {
capture.CleanupHVNCDesktop() capture.CleanupHVNCDesktop()
return nil return nil
case <-ticker.C: case <-ticker.C:
fps := activeHVNCTargetFPS()
if fps != currentFPS {
currentFPS = fps
capture.SetH264TargetFPS(fps)
capture.SetFrameFlowTargetFPS(fps)
ticker.Reset(time.Second / time.Duration(fps))
log.Printf("hvnc: target fps changed to %d", fps)
}
if err := capture.NowHVNC(ctx, env); err != nil { if err := capture.NowHVNC(ctx, env); err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
log.Printf("hvnc: stopping stream") log.Printf("hvnc: stopping stream")
@@ -66,6 +79,22 @@ func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error {
} }
} }
func activeHVNCTargetFPS() int {
if fps := int(hvncTargetFPS.Load()); fps > 0 {
return fps
}
_, fps := streamInterval("OVERLORD_HVNC_MAX_FPS", 120)
return SetHVNCTargetFPS(fps)
}
func SetHVNCTargetFPS(fps int) int {
fps = clampDesktopTargetFPS(fps)
hvncTargetFPS.Store(int64(fps))
capture.SetH264TargetFPS(fps)
capture.SetFrameFlowTargetFPS(fps)
return fps
}
func HVNCSelect(ctx context.Context, env *rt.Env, display int) error { func HVNCSelect(ctx context.Context, env *rt.Env, display int) error {
prev := env.HVNCSelectedDisplay prev := env.HVNCSelectedDisplay
maxDisplays := capture.HVNCMonitorCount() maxDisplays := capture.HVNCMonitorCount()
+12 -2
View File
@@ -25,6 +25,8 @@ func pcm16BytesToInt16(chunk []byte) []int16 {
func kindFromPayload(payload map[string]interface{}) webrtcpub.Kind { func kindFromPayload(payload map[string]interface{}) webrtcpub.Kind {
switch s, _ := payload["kind"].(string); s { switch s, _ := payload["kind"].(string); s {
case "hvnc":
return webrtcpub.KindHVNC
case "webcam": case "webcam":
return webrtcpub.KindWebcam return webrtcpub.KindWebcam
case "audio": case "audio":
@@ -79,7 +81,11 @@ func handleWebrtcPublish(ctx context.Context, env *runtime.Env, cmdID string, pa
// Force a fresh SPS/PPS/IDR so the freshly subscribed viewer can // Force a fresh SPS/PPS/IDR so the freshly subscribed viewer can
// decode immediately instead of waiting for the next natural IDR. // decode immediately instead of waiting for the next natural IDR.
if hasVideo { if hasVideo {
capture.ResetPrev() if kind == webrtcpub.KindHVNC {
capture.ResetPrevHVNC()
} else {
capture.ResetPrev()
}
} }
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ _ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
Type: "command_result", CommandID: cmdID, OK: true, Type: "command_result", CommandID: cmdID, OK: true,
@@ -136,7 +142,11 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
return nil return nil
} }
if hasVideo { if hasVideo {
capture.ResetPrev() if kind == webrtcpub.KindHVNC {
capture.ResetPrevHVNC()
} else {
capture.ResetPrev()
}
} }
_ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PAnswer{ _ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PAnswer{
Type: "webrtc_p2p_answer", Type: "webrtc_p2p_answer",
@@ -10,6 +10,7 @@ type Kind string
const ( const (
KindDesktop Kind = "desktop" KindDesktop Kind = "desktop"
KindHVNC Kind = "hvnc"
KindWebcam Kind = "webcam" KindWebcam Kind = "webcam"
KindAudio Kind = "audio" KindAudio Kind = "audio"
) )
+1 -1
View File
@@ -47,7 +47,7 @@ bun run build:mac # macOS DMG
bun run build:linux # Linux AppImage bun run build:linux # Linux AppImage
``` ```
Or from the repo root: `./build-desktop.sh` (or `build-desktop.bat` on Windows) picks the right target for the current OS. Or from the repo root: `./scripts/build-desktop.sh` (or `scripts\build-desktop.bat` on Windows) picks the right target for the current OS.
Output lands in `src-tauri/target/release/bundle/`. The CI workflow in `.github/workflows/desktop-release.yml` builds the Windows NSIS installer on every push to `main` that touches `Overlord-Desktop/` and publishes it as a GitHub Release tagged `Overlord-Desktop-v<version>`. Output lands in `src-tauri/target/release/bundle/`. The CI workflow in `.github/workflows/desktop-release.yml` builds the Windows NSIS installer on every push to `main` that touches `Overlord-Desktop/` and publishes it as a GitHub Release tagged `Overlord-Desktop-v<version>`.
+261 -16
View File
@@ -1,6 +1,9 @@
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js"; import { checkFeatureAccess } from "./feature-gate.js";
import { createKeyboardCapture } from "./keyboard-capture.js"; import { createKeyboardCapture } from "./keyboard-capture.js";
import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js";
(async function () { (async function () {
const clientId = new URLSearchParams(location.search).get("clientId"); const clientId = new URLSearchParams(location.search).get("clientId");
@@ -65,6 +68,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
const qualityValue = document.getElementById("qualityValue"); const qualityValue = document.getElementById("qualityValue");
const codecH264 = document.getElementById("codecH264"); const codecH264 = document.getElementById("codecH264");
const codecMode = document.getElementById("codecMode"); const codecMode = document.getElementById("codecMode");
const webrtcMode = document.getElementById("webrtcMode");
const webrtcVideo = document.getElementById("webrtcVideo");
const canvas = document.getElementById("frameCanvas"); const canvas = document.getElementById("frameCanvas");
const canvasContainer = document.getElementById("canvasContainer"); const canvasContainer = document.getElementById("canvasContainer");
const contextMenu = document.getElementById("hvncContextMenu"); const contextMenu = document.getElementById("hvncContextMenu");
@@ -76,6 +81,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
const dxgiCtrl = document.getElementById("dxgiCtrl"); const dxgiCtrl = document.getElementById("dxgiCtrl");
const uiaCtrl = document.getElementById("uiaCtrl"); const uiaCtrl = document.getElementById("uiaCtrl");
const hvncResolutionSelect = document.getElementById("hvncResolutionSelect"); const hvncResolutionSelect = document.getElementById("hvncResolutionSelect");
let whepClient = null;
let p2pClient = null;
let webrtcActive = false;
function getWebrtcMode() {
return webrtcMode ? String(webrtcMode.value || "off") : "off";
}
function syncInputEnableState() { function syncInputEnableState() {
if (mouseCtrl) sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked }); if (mouseCtrl) sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked });
@@ -96,7 +108,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
let moveTimer = null; let moveTimer = null;
let videoDecoder = null; let videoDecoder = null;
let h264TimestampUs = 0; let h264TimestampUs = 0;
const codecPrefKey = "hvncCodecPreferH264";
let prefersH264 = typeof VideoDecoder === "function"; let prefersH264 = typeof VideoDecoder === "function";
let lastMoveSentAt = 0; let lastMoveSentAt = 0;
const mouseMoveIntervalMs = 33; const mouseMoveIntervalMs = 33;
@@ -108,12 +119,68 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
let lastClipboardText = ""; let lastClipboardText = "";
let clipboardSyncActive = false; let clipboardSyncActive = false;
const storedCodecPref = localStorage.getItem(codecPrefKey); let savedDisplay = null;
if (storedCodecPref === "0") {
prefersH264 = false; function setSelectValue(select, value) {
} else if (storedCodecPref === "1") { if (!select || value === undefined || value === null) return false;
prefersH264 = typeof VideoDecoder === "function"; const normalized = String(value);
const exists = Array.from(select.options || []).some((opt) => opt.value === normalized);
if (!exists) return false;
select.value = normalized;
return true;
} }
function applySavedDisplay() {
if (savedDisplay === null || savedDisplay === undefined) return;
setSelectValue(displaySelect, savedDisplay);
}
function applySharedSettings(settings) {
if (!settings || typeof settings !== "object") return;
savedDisplay = Number.isFinite(Number(settings.display)) ? Number(settings.display) : savedDisplay;
setSelectValue(hvncResolutionSelect, settings.resolution);
setSelectValue(webrtcMode, settings.webrtcMode);
if (qualitySlider && settings.quality !== undefined) qualitySlider.value = String(settings.quality);
if (mouseCtrl && typeof settings.mouse === "boolean") mouseCtrl.checked = settings.mouse;
if (kbdCtrl && typeof settings.keyboard === "boolean") kbdCtrl.checked = settings.keyboard;
if (clipboardSyncCtrl && typeof settings.clipboardSync === "boolean") clipboardSyncCtrl.checked = settings.clipboardSync;
if (dxgiCtrl && typeof settings.dxgi === "boolean") dxgiCtrl.checked = settings.dxgi;
if (uiaCtrl && typeof settings.uia === "boolean") uiaCtrl.checked = settings.uia;
if (typeof settings.preferH264 === "boolean") {
prefersH264 = settings.preferH264 && typeof VideoDecoder === "function";
}
const cloneToggle = document.getElementById("hvncCloneToggle");
const cloneLiteToggle = document.getElementById("hvncCloneLiteToggle");
const killIfRunningToggle = document.getElementById("hvncKillIfRunningToggle");
if (cloneToggle && typeof settings.cloneProfile === "boolean") cloneToggle.checked = settings.cloneProfile;
if (cloneLiteToggle && typeof settings.cloneLite === "boolean") cloneLiteToggle.checked = settings.cloneLite;
if (killIfRunningToggle && typeof settings.killIfRunning === "boolean") {
killIfRunningToggle.checked = settings.killIfRunning;
}
applySavedDisplay();
}
function readSharedSettings() {
return {
display: Number(displaySelect?.value || 0),
resolution: hvncResolutionSelect?.value || "1080",
quality: Number(qualitySlider?.value || 90),
preferH264: !!prefersH264,
webrtcMode: getWebrtcMode(),
mouse: !!mouseCtrl?.checked,
keyboard: !!kbdCtrl?.checked,
clipboardSync: !!clipboardSyncCtrl?.checked,
dxgi: !!dxgiCtrl?.checked,
uia: !!uiaCtrl?.checked,
cloneProfile: document.getElementById("hvncCloneToggle")?.checked !== false,
cloneLite: document.getElementById("hvncCloneLiteToggle")?.checked === true,
killIfRunning: document.getElementById("hvncKillIfRunningToggle")?.checked === true,
};
}
applySharedSettings(await loadSharedUiSettings("hvnc"));
const sharedSettingsSaver = createSharedUiSettingsSaver("hvnc", readSharedSettings);
if (codecH264) { if (codecH264) {
codecH264.checked = prefersH264; codecH264.checked = prefersH264;
codecH264.disabled = typeof VideoDecoder !== "function"; codecH264.disabled = typeof VideoDecoder !== "function";
@@ -253,7 +320,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
if (clipboardSyncCtrl) { if (clipboardSyncCtrl) {
clipboardSyncCtrl.addEventListener("change", checkClipboardSync); clipboardSyncCtrl.addEventListener("change", function () {
checkClipboardSync();
sharedSettingsSaver.scheduleSave();
});
} }
function clearOfflineTimer() { function clearOfflineTimer() {
@@ -290,6 +360,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
clearOfflineTimer(); clearOfflineTimer();
if (desiredStreaming) { if (desiredStreaming) {
setStreamState("starting", "Reconnecting"); setStreamState("starting", "Reconnecting");
const mode = getWebrtcMode();
if (displaySelect && displaySelect.value !== undefined) { if (displaySelect && displaySelect.value !== undefined) {
sendCmd("hvnc_select_display", { sendCmd("hvnc_select_display", {
display: parseInt(displaySelect.value, 10) || 0, display: parseInt(displaySelect.value, 10) || 0,
@@ -297,7 +368,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
sendCmd("hvnc_start", { sendCmd("hvnc_start", {
autoStartExplorer: false, autoStartExplorer: false,
webrtc: mode === "relayed",
}); });
if (mode === "p2p") startP2P();
syncInputEnableState(); syncInputEnableState();
} else { } else {
setStreamState("idle", "Stopped"); setStreamState("idle", "Stopped");
@@ -672,6 +745,110 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
ws.send(encodeMsgpack(msg)); ws.send(encodeMsgpack(msg));
} }
function setWebrtcViewActive(active) {
webrtcActive = !!active;
if (canvas) canvas.style.display = active ? "none" : "block";
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
}
function onWebrtcState(label, state) {
console.debug(`hvnc webrtc[${label}]: state`, state);
if (state === "connected") {
setStreamState("streaming", `Streaming (${label})`);
} else if (state === "failed" || state === "disconnected") {
setWebrtcViewActive(false);
}
}
let webrtcRvfcHandle = 0;
let webrtcFpsCount = 0;
let webrtcFpsWindowStart = 0;
function startWebrtcFrameTicker() {
if (!webrtcVideo || typeof webrtcVideo.requestVideoFrameCallback !== "function") return;
stopWebrtcFrameTicker();
webrtcFpsCount = 0;
webrtcFpsWindowStart = performance.now();
const tick = (now) => {
lastFrameAt = performance.now();
clearOfflineTimer();
webrtcFpsCount += 1;
const elapsed = now - webrtcFpsWindowStart;
if (elapsed >= 1000) {
const fps = Math.round((webrtcFpsCount * 1000) / elapsed);
updateFpsDisplay(fps);
webrtcFpsCount = 0;
webrtcFpsWindowStart = now;
} else {
updateFpsDisplay();
}
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
};
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
}
function stopWebrtcFrameTicker() {
if (webrtcRvfcHandle && webrtcVideo && typeof webrtcVideo.cancelVideoFrameCallback === "function") {
webrtcVideo.cancelVideoFrameCallback(webrtcRvfcHandle);
}
webrtcRvfcHandle = 0;
}
async function startWhep(whepPath) {
await stopAllWebrtc();
if (!webrtcVideo) return;
whepClient = new WhepClient({
whepPath,
videoEl: webrtcVideo,
onState: (state) => onWebrtcState("WebRTC Relayed", state),
});
try {
await whepClient.start();
setWebrtcViewActive(true);
startWebrtcFrameTicker();
} catch (err) {
console.warn("hvnc webrtc: WHEP start failed, falling back to canvas", err);
setWebrtcViewActive(false);
whepClient = null;
}
}
async function startP2P() {
await stopAllWebrtc();
if (!webrtcVideo) return;
p2pClient = new P2PClient({
videoEl: webrtcVideo,
send: (msg) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(encodeMsgpack(msg));
}
},
onState: (state) => onWebrtcState("WebRTC P2P", state),
});
try {
await p2pClient.start();
setWebrtcViewActive(true);
startWebrtcFrameTicker();
} catch (err) {
console.warn("hvnc webrtc: P2P start failed, falling back to canvas", err);
setWebrtcViewActive(false);
const client = p2pClient;
p2pClient = null;
if (client) { try { await client.stop(); } catch {} }
}
}
async function stopAllWebrtc() {
stopWebrtcFrameTicker();
setWebrtcViewActive(false);
const whep = whepClient;
whepClient = null;
if (whep) { try { await whep.stop(); } catch {} }
const p2p = p2pClient;
p2pClient = null;
if (p2p) { try { await p2p.stop(); } catch {} }
}
let monitors = 1; let monitors = 1;
function populateDisplays(count) { function populateDisplays(count) {
@@ -687,6 +864,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (displaySelect.options.length) { if (displaySelect.options.length) {
displaySelect.value = displaySelect.options[0].value; displaySelect.value = displaySelect.options[0].value;
} }
applySavedDisplay();
} }
async function fetchClientInfo() { async function fetchClientInfo() {
@@ -699,6 +877,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
if (client && client.monitors) { if (client && client.monitors) {
populateDisplays(client.monitors); populateDisplays(client.monitors);
applySavedDisplay();
} }
} catch (e) { } catch (e) {
console.warn("failed to fetch client info", e); console.warn("failed to fetch client info", e);
@@ -721,16 +900,28 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
sendCmd("hvnc_set_quality", { quality: q, codec }); sendCmd("hvnc_set_quality", { quality: q, codec });
} }
function pushTransportQuality(mode) {
if (mode === "relayed" || mode === "p2p") {
const q = Number(qualitySlider?.value) || 90;
setCodecModeLabel("h264", "webrtc");
sendCmd("hvnc_set_quality", { quality: q, codec: "h264", source: "webrtc" });
return;
}
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
}
if (codecH264) { if (codecH264) {
codecH264.addEventListener("change", function () { codecH264.addEventListener("change", function () {
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
if (!prefersH264) { if (!prefersH264) {
destroyVideoDecoder(); destroyVideoDecoder();
} }
if (qualitySlider) { if (qualitySlider) {
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
} }
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -769,7 +960,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
}, 5000); }, 5000);
} else if (h264ErrorCount > 3) { } else if (h264ErrorCount > 3) {
localStorage.setItem(codecPrefKey, "0"); sharedSettingsSaver.scheduleSave();
} }
} }
@@ -834,29 +1025,39 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
sendCmd("hvnc_select_display", { sendCmd("hvnc_select_display", {
display: parseInt(displaySelect.value, 10), display: parseInt(displaySelect.value, 10),
}); });
sharedSettingsSaver.scheduleSave();
}); });
if (webrtcMode) {
webrtcMode.addEventListener("change", function () {
updateControls();
sharedSettingsSaver.scheduleSave();
});
}
startBtn.addEventListener("click", function () { startBtn.addEventListener("click", function () {
const mode = getWebrtcMode();
if (displaySelect && displaySelect.value !== undefined) { if (displaySelect && displaySelect.value !== undefined) {
sendCmd("hvnc_select_display", { sendCmd("hvnc_select_display", {
display: parseInt(displaySelect.value, 10) || 0, display: parseInt(displaySelect.value, 10) || 0,
}); });
} }
if (qualitySlider) { pushTransportQuality(mode);
pushQuality(qualitySlider.value);
}
desiredStreaming = true; desiredStreaming = true;
lastFrameAt = 0; lastFrameAt = 0;
setStreamState("starting", "Starting stream"); setStreamState("starting", "Starting stream");
sendCmd("hvnc_start", { sendCmd("hvnc_start", {
autoStartExplorer: false, autoStartExplorer: false,
webrtc: mode === "relayed",
}); });
if (mode === "p2p") startP2P();
syncInputEnableState(); syncInputEnableState();
}); });
stopBtn.addEventListener("click", function () { stopBtn.addEventListener("click", function () {
desiredStreaming = false; desiredStreaming = false;
setStreamState("stopping", "Stopping stream"); setStreamState("stopping", "Stopping stream");
sendCmd("hvnc_stop", {}); sendCmd("hvnc_stop", {});
stopAllWebrtc();
}); });
if (killAllBtn) { if (killAllBtn) {
killAllBtn.addEventListener("click", function () { killAllBtn.addEventListener("click", function () {
@@ -874,18 +1075,22 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
mouseCtrl.addEventListener("change", function () { mouseCtrl.addEventListener("change", function () {
sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked }); sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked });
sharedSettingsSaver.scheduleSave();
}); });
kbdCtrl.addEventListener("change", function () { kbdCtrl.addEventListener("change", function () {
sendCmd("hvnc_enable_keyboard", { enabled: kbdCtrl.checked }); sendCmd("hvnc_enable_keyboard", { enabled: kbdCtrl.checked });
sharedSettingsSaver.scheduleSave();
}); });
if (dxgiCtrl) { if (dxgiCtrl) {
dxgiCtrl.addEventListener("change", function () { dxgiCtrl.addEventListener("change", function () {
sendCmd("hvnc_enable_dxgi", { enabled: dxgiCtrl.checked }); sendCmd("hvnc_enable_dxgi", { enabled: dxgiCtrl.checked });
sharedSettingsSaver.scheduleSave();
}); });
} }
if (uiaCtrl) { if (uiaCtrl) {
uiaCtrl.addEventListener("change", function () { uiaCtrl.addEventListener("change", function () {
sendCmd("hvnc_enable_uia", { enabled: uiaCtrl.checked }); sendCmd("hvnc_enable_uia", { enabled: uiaCtrl.checked });
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -898,6 +1103,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (hvncResolutionSelect) { if (hvncResolutionSelect) {
hvncResolutionSelect.addEventListener("change", function () { hvncResolutionSelect.addEventListener("change", function () {
pushHvncResolution(); pushHvncResolution();
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -906,6 +1112,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
qualitySlider.addEventListener("input", function () { qualitySlider.addEventListener("input", function () {
updateQualityLabel(qualitySlider.value); updateQualityLabel(qualitySlider.value);
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1036,6 +1243,18 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
handleStatus(msg); handleStatus(msg);
return; return;
} }
if (msg && msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
startWhep(msg.whepPath);
return;
}
if (msg && msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
if (p2pClient) p2pClient.onAnswer(msg.sdp);
return;
}
if (msg && msg.type === "webrtc_p2p_ice") {
if (p2pClient) p2pClient.onRemoteCandidate(msg);
return;
}
if (msg && msg.type === "hvnc_clone_progress") { if (msg && msg.type === "hvnc_clone_progress") {
handleCloneProgress(msg); handleCloneProgress(msg);
return; return;
@@ -1083,6 +1302,18 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
handleStatus(msg); handleStatus(msg);
return; return;
} }
if (msg && msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
startWhep(msg.whepPath);
return;
}
if (msg && msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
if (p2pClient) p2pClient.onAnswer(msg.sdp);
return;
}
if (msg && msg.type === "webrtc_p2p_ice") {
if (p2pClient) p2pClient.onRemoteCandidate(msg);
return;
}
if (msg && msg.type === "hvnc_clone_progress") { if (msg && msg.type === "hvnc_clone_progress") {
handleCloneProgress(msg); handleCloneProgress(msg);
return; return;
@@ -1122,16 +1353,16 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
function onWsOpen() { function onWsOpen() {
reconnectDelay = 1000; reconnectDelay = 1000;
if (qualitySlider) { pushTransportQuality(desiredStreaming ? getWebrtcMode() : "off");
pushQuality(qualitySlider.value);
}
clearOfflineTimer(); clearOfflineTimer();
if (desiredStreaming) { if (desiredStreaming) {
setStreamState("starting", "Resuming stream"); setStreamState("starting", "Resuming stream");
const mode = getWebrtcMode();
if (displaySelect && displaySelect.value !== undefined) { if (displaySelect && displaySelect.value !== undefined) {
sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 }); sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 });
} }
sendCmd("hvnc_start", { autoStartExplorer: false }); sendCmd("hvnc_start", { autoStartExplorer: false, webrtc: mode === "relayed" });
if (mode === "p2p") startP2P();
syncInputEnableState(); syncInputEnableState();
} else { } else {
setStreamState("idle", "Stopped"); setStreamState("idle", "Stopped");
@@ -1149,6 +1380,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
function onWsClose() { function onWsClose() {
destroyVideoDecoder(); destroyVideoDecoder();
stopAllWebrtc();
if (desiredStreaming) { if (desiredStreaming) {
setStreamState("connecting", "Reconnecting"); setStreamState("connecting", "Reconnecting");
scheduleReconnect(); scheduleReconnect();
@@ -1159,6 +1391,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
function onWsError() { function onWsError() {
destroyVideoDecoder(); destroyVideoDecoder();
stopAllWebrtc();
setStreamState("error", "WebSocket error"); setStreamState("error", "WebSocket error");
} }
@@ -1270,6 +1503,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (kbdCtrl.checked) kbdCapture.enable(); if (kbdCtrl.checked) kbdCapture.enable();
else kbdCapture.disable(); else kbdCapture.disable();
}); });
if (kbdCtrl.checked) kbdCapture.enable();
} }
document.addEventListener("fullscreenchange", function () { document.addEventListener("fullscreenchange", function () {
if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) { if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) {
@@ -1279,11 +1513,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
function stopOnExit() { function stopOnExit() {
sharedSettingsSaver.saveNow();
if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) { if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) {
desiredStreaming = false; desiredStreaming = false;
sendCmd("hvnc_stop", {}); sendCmd("hvnc_stop", {});
} }
destroyVideoDecoder(); destroyVideoDecoder();
stopAllWebrtc();
} }
window.addEventListener("beforeunload", stopOnExit); window.addEventListener("beforeunload", stopOnExit);
@@ -1320,6 +1556,15 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
} }
["hvncCloneToggle", "hvncCloneLiteToggle", "hvncKillIfRunningToggle"].forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener("change", function () {
sharedSettingsSaver.scheduleSave();
});
}
});
if (contextMenu) { if (contextMenu) {
contextMenu.querySelectorAll("[data-action]").forEach((item) => { contextMenu.querySelectorAll("[data-action]").forEach((item) => {
item.addEventListener("click", (e) => { item.addEventListener("click", (e) => {
+191 -46
View File
@@ -3,6 +3,7 @@ import { checkFeatureAccess } from "./feature-gate.js";
import { WhepClient } from "./whep.js"; import { WhepClient } from "./whep.js";
import { P2PClient } from "./webrtc-p2p.js"; import { P2PClient } from "./webrtc-p2p.js";
import { createKeyboardCapture } from "./keyboard-capture.js"; import { createKeyboardCapture } from "./keyboard-capture.js";
import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js";
(async function () { (async function () {
const clientId = new URLSearchParams(location.search).get("clientId"); const clientId = new URLSearchParams(location.search).get("clientId");
@@ -17,13 +18,38 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
const clientLabel = document.getElementById("clientLabel"); const clientLabel = document.getElementById("clientLabel");
clientLabel.textContent = clientId; clientLabel.textContent = clientId;
const ws = new WebSocket( let ws = null;
(location.protocol === "https:" ? "wss://" : "ws://") + let reconnectTimer = null;
let reconnectDelay = 1000;
const maxReconnectDelay = 15000;
function buildWsUrl() {
return (location.protocol === "https:" ? "wss://" : "ws://") +
location.host + location.host +
"/api/clients/" + "/api/clients/" +
clientId + clientId +
"/rd/ws", "/rd/ws";
); }
function connectWs() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
ws = new WebSocket(buildWsUrl());
ws.binaryType = "arraybuffer";
ws.addEventListener("open", onWsOpen);
ws.addEventListener("message", onWsMessage);
ws.addEventListener("close", onWsClose);
ws.addEventListener("error", onWsError);
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
setStreamState("connecting", "Reconnecting");
connectWs();
reconnectDelay = Math.min(reconnectDelay * 1.5, maxReconnectDelay);
}, reconnectDelay);
}
const displaySelect = document.getElementById("displaySelect"); const displaySelect = document.getElementById("displaySelect");
const refreshBtn = document.getElementById("refreshDisplays"); const refreshBtn = document.getElementById("refreshDisplays");
const startBtn = document.getElementById("startBtn"); const startBtn = document.getElementById("startBtn");
@@ -62,8 +88,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
function getWebrtcMode() { function getWebrtcMode() {
return webrtcMode ? String(webrtcMode.value || "off") : "off"; return webrtcMode ? String(webrtcMode.value || "off") : "off";
} }
ws.binaryType = "arraybuffer";
let activeClientId = clientId; let activeClientId = clientId;
let serverRecording = null; let serverRecording = null;
let recordingTimer = null; let recordingTimer = null;
@@ -86,7 +110,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
let pendingFrame = null; let pendingFrame = null;
let videoDecoder = null; let videoDecoder = null;
let h264TimestampUs = 0; let h264TimestampUs = 0;
const codecPrefKey = "rdCodecPreferH264";
let prefersH264 = typeof VideoDecoder === "function"; let prefersH264 = typeof VideoDecoder === "function";
let h264LowFpsStreak = 0; let h264LowFpsStreak = 0;
let h264FirstFrameAt = 0; let h264FirstFrameAt = 0;
@@ -346,12 +369,72 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
h264LastDecodeWarnAt = 0; h264LastDecodeWarnAt = 0;
} }
const storedCodecPref = localStorage.getItem(codecPrefKey); let savedDisplay = null;
if (storedCodecPref === "0") {
prefersH264 = false; function setSelectValue(select, value) {
} else if (storedCodecPref === "1") { if (!select || value === undefined || value === null) return false;
prefersH264 = typeof VideoDecoder === "function"; const normalized = String(value);
const exists = Array.from(select.options || []).some((opt) => opt.value === normalized);
if (!exists) return false;
select.value = normalized;
return true;
} }
function applySavedDisplay() {
if (savedDisplay === null || savedDisplay === undefined) return;
setSelectValue(displaySelect, savedDisplay);
}
function applySharedSettings(settings) {
if (!settings || typeof settings !== "object") return;
savedDisplay = Number.isFinite(Number(settings.display)) ? Number(settings.display) : savedDisplay;
setSelectValue(resolutionSelect, settings.resolution);
setSelectValue(targetFpsSelect, settings.targetFps);
setSelectValue(webrtcMode, settings.webrtcMode);
setSelectValue(audioTransport, settings.audioTransport);
setSelectValue(recordMode, settings.recordMode);
setSelectValue(recordFps, settings.recordFps);
if (qualitySlider && settings.quality !== undefined) qualitySlider.value = String(settings.quality);
if (smoothingSlider && settings.smoothing !== undefined) {
smoothingSlider.value = String(settings.smoothing);
smoothingPct = Number(smoothingSlider.value) || 0;
}
if (mouseCtrl && typeof settings.mouse === "boolean") mouseCtrl.checked = settings.mouse;
if (kbdCtrl && typeof settings.keyboard === "boolean") kbdCtrl.checked = settings.keyboard;
if (cursorCtrl && typeof settings.cursor === "boolean") cursorCtrl.checked = settings.cursor;
if (duplicationCtrl && typeof settings.duplication === "boolean") duplicationCtrl.checked = settings.duplication;
if (clipboardSyncCtrl && typeof settings.clipboardSync === "boolean") clipboardSyncCtrl.checked = settings.clipboardSync;
if (audioCtrl && typeof settings.audio === "boolean") audioCtrl.checked = settings.audio;
if (typeof settings.preferH264 === "boolean") {
prefersH264 = settings.preferH264 && typeof VideoDecoder === "function";
}
applySavedDisplay();
}
function readSharedSettings() {
return {
display: Number(displaySelect?.value || 0),
resolution: resolutionSelect?.value || "1080",
targetFps: targetFpsSelect?.value || "120",
quality: Number(qualitySlider?.value || 90),
preferH264: !!prefersH264,
webrtcMode: getWebrtcMode(),
mouse: !!mouseCtrl?.checked,
keyboard: !!kbdCtrl?.checked,
cursor: !!cursorCtrl?.checked,
duplication: !!duplicationCtrl?.checked,
clipboardSync: !!clipboardSyncCtrl?.checked,
audio: !!audioCtrl?.checked,
audioTransport: getAudioTransport(),
smoothing: Number(smoothingSlider?.value || 20),
recordMode: recordMode?.value || "normal",
recordFps: recordFps?.value || "",
};
}
applySharedSettings(await loadSharedUiSettings("remote_desktop"));
const sharedSettingsSaver = createSharedUiSettingsSaver("remote_desktop", readSharedSettings);
if (codecH264) { if (codecH264) {
codecH264.checked = prefersH264; codecH264.checked = prefersH264;
codecH264.disabled = typeof VideoDecoder !== "function"; codecH264.disabled = typeof VideoDecoder !== "function";
@@ -438,7 +521,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
function updateControls() { function updateControls() {
const wsOpen = ws.readyState === WebSocket.OPEN; const wsOpen = ws && ws.readyState === WebSocket.OPEN;
const isStarting = streamState === "starting"; const isStarting = streamState === "starting";
const isStreaming = streamState === "streaming"; const isStreaming = streamState === "streaming";
const isStopping = streamState === "stopping"; const isStopping = streamState === "stopping";
@@ -707,7 +790,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
if (clipboardSyncCtrl) { if (clipboardSyncCtrl) {
clipboardSyncCtrl.addEventListener("change", checkClipboardSync); clipboardSyncCtrl.addEventListener("change", function () {
checkClipboardSync();
sharedSettingsSaver.scheduleSave();
});
} }
function updateLatency(ms) { function updateLatency(ms) {
@@ -778,18 +864,31 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
desiredStreaming = true; desiredStreaming = true;
} }
if (desiredStreaming) { if (desiredStreaming) {
setStreamState("starting", "Reconnecting"); const shouldRequestStart = !["starting", "streaming", "stalled"].includes(streamState);
const mode = getWebrtcMode();
if (shouldRequestStart) {
setStreamState("starting", "Reconnecting");
}
if (displaySelect && displaySelect.value !== undefined) { if (displaySelect && displaySelect.value !== undefined) {
sendCmd("desktop_select_display", { sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10) || 0, display: parseInt(displaySelect.value, 10) || 0,
}); });
} }
sendCmd("desktop_start", {});
pushInputToggles(); pushInputToggles();
pushCaptureToggles(); pushCaptureToggles();
if (qualitySlider) pushQuality(qualitySlider.value); if (qualitySlider) pushQuality(qualitySlider.value);
pushTargetFps(); pushTargetFps();
pushResolution(); pushResolution();
if (shouldRequestStart) {
if (mode === "relayed") {
sendCmd("desktop_start", { webrtc: true });
} else if (mode === "p2p") {
sendCmd("desktop_start", {});
startP2P();
} else {
sendCmd("desktop_start", {});
}
}
} else { } else {
setStreamState("idle", "Stopped"); setStreamState("idle", "Stopped");
} }
@@ -898,10 +997,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
console.warn("No active client selected"); console.warn("No active client selected");
return; return;
} }
if (ws.readyState !== WebSocket.OPEN) { if (!ws || ws.readyState !== WebSocket.OPEN) {
rdDebug("send skipped websocket not open", { rdDebug("send skipped websocket not open", {
type, type,
readyState: ws.readyState, readyState: ws ? ws.readyState : -1,
streamState, streamState,
desiredStreaming, desiredStreaming,
}); });
@@ -1049,6 +1148,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (displaySelect.options.length) { if (displaySelect.options.length) {
displaySelect.value = displaySelect.options[0].value; displaySelect.value = displaySelect.options[0].value;
} }
applySavedDisplay();
} }
async function fetchClientInfo() { async function fetchClientInfo() {
@@ -1070,6 +1170,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
if (client) { if (client) {
populateDisplays(client.monitors, client.monitorInfo); populateDisplays(client.monitors, client.monitorInfo);
applySavedDisplay();
} }
if (duplicationCtrl) { if (duplicationCtrl) {
const os = (client?.os || "").toLowerCase(); const os = (client?.os || "").toLowerCase();
@@ -1121,7 +1222,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
codecH264.addEventListener("change", function () { codecH264.addEventListener("change", function () {
resetH264SessionState(); resetH264SessionState();
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
if (prefersH264) { if (prefersH264) {
ensureDuplicationForH264(); ensureDuplicationForH264();
} }
@@ -1132,6 +1232,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (qualitySlider) { if (qualitySlider) {
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
} }
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1157,12 +1258,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (resolutionSelect) { if (resolutionSelect) {
resolutionSelect.addEventListener("change", function () { resolutionSelect.addEventListener("change", function () {
pushResolution(); pushResolution();
sharedSettingsSaver.scheduleSave();
}); });
} }
if (targetFpsSelect) { if (targetFpsSelect) {
targetFpsSelect.addEventListener("change", function () { targetFpsSelect.addEventListener("change", function () {
pushTargetFps(); pushTargetFps();
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1171,11 +1274,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
sendCmd("desktop_select_display", { sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10), display: parseInt(displaySelect.value, 10),
}); });
sharedSettingsSaver.scheduleSave();
}); });
if (webrtcMode) { if (webrtcMode) {
webrtcMode.addEventListener("change", function () { webrtcMode.addEventListener("change", function () {
updateControls(); updateControls();
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1236,6 +1341,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} else { } else {
sendCmd("desktop_start", {}); sendCmd("desktop_start", {});
} }
if (audioCtrl && audioCtrl.checked) {
connectAudio();
}
}); });
stopBtn.addEventListener("click", function () { stopBtn.addEventListener("click", function () {
if (isRecording()) stopRecording(); if (isRecording()) stopRecording();
@@ -1251,6 +1359,16 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
else startRecording(); else startRecording();
}); });
} }
if (recordMode) {
recordMode.addEventListener("change", function () {
sharedSettingsSaver.scheduleSave();
});
}
if (recordFps) {
recordFps.addEventListener("change", function () {
sharedSettingsSaver.scheduleSave();
});
}
fullscreenBtn.addEventListener("click", function () { fullscreenBtn.addEventListener("click", function () {
if (canvasContainer.requestFullscreen) { if (canvasContainer.requestFullscreen) {
canvasContainer.requestFullscreen(); canvasContainer.requestFullscreen();
@@ -1260,16 +1378,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
canvasContainer.mozRequestFullScreen(); canvasContainer.mozRequestFullScreen();
} }
}); });
if (mouseCtrl) {
mouseCtrl.checked = false;
}
if (kbdCtrl) {
kbdCtrl.checked = false;
}
if (duplicationCtrl) {
duplicationCtrl.checked = false;
}
function pushInputToggles() { function pushInputToggles() {
if (mouseCtrl) { if (mouseCtrl) {
sendCmd("desktop_enable_mouse", { enabled: !!mouseCtrl.checked }); sendCmd("desktop_enable_mouse", { enabled: !!mouseCtrl.checked });
@@ -1290,6 +1398,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
mouseCtrl.addEventListener("change", function () { mouseCtrl.addEventListener("change", function () {
pushInputToggles(); pushInputToggles();
sharedSettingsSaver.scheduleSave();
}); });
const kbdCapture = createKeyboardCapture({ const kbdCapture = createKeyboardCapture({
container: canvas, container: canvas,
@@ -1301,7 +1410,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
if (kbdCtrl.checked) kbdCapture.enable(); if (kbdCtrl.checked) kbdCapture.enable();
else kbdCapture.disable(); else kbdCapture.disable();
pushInputToggles(); pushInputToggles();
sharedSettingsSaver.scheduleSave();
}); });
if (kbdCtrl.checked) kbdCapture.enable();
document.addEventListener("fullscreenchange", function () { document.addEventListener("fullscreenchange", function () {
if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) { if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) {
kbdCtrl.checked = true; kbdCtrl.checked = true;
@@ -1310,10 +1421,12 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
cursorCtrl.addEventListener("change", function () { cursorCtrl.addEventListener("change", function () {
pushCaptureToggles(); pushCaptureToggles();
sharedSettingsSaver.scheduleSave();
}); });
if (duplicationCtrl) { if (duplicationCtrl) {
duplicationCtrl.addEventListener("change", function () { duplicationCtrl.addEventListener("change", function () {
pushCaptureToggles(); pushCaptureToggles();
sharedSettingsSaver.scheduleSave();
}); });
} }
if (audioCtrl) { if (audioCtrl) {
@@ -1323,6 +1436,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} else { } else {
disconnectAudio(); disconnectAudio();
} }
sharedSettingsSaver.scheduleSave();
}); });
} }
if (audioTransport) { if (audioTransport) {
@@ -1333,6 +1447,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
disconnectAudio(false); disconnectAudio(false);
connectAudio(); connectAudio();
} }
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1341,6 +1456,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
qualitySlider.addEventListener("input", function () { qualitySlider.addEventListener("input", function () {
updateQualityLabel(qualitySlider.value); updateQualityLabel(qualitySlider.value);
pushQuality(qualitySlider.value); pushQuality(qualitySlider.value);
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1349,6 +1465,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
smoothingSlider.addEventListener("input", function () { smoothingSlider.addEventListener("input", function () {
smoothingPct = Number(smoothingSlider.value) || 0; smoothingPct = Number(smoothingSlider.value) || 0;
updateSmoothingLabel(smoothingSlider.value); updateSmoothingLabel(smoothingSlider.value);
sharedSettingsSaver.scheduleSave();
}); });
} }
@@ -1455,7 +1572,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
prefersH264 = false; prefersH264 = false;
destroyVideoDecoder(); destroyVideoDecoder();
if (codecH264) codecH264.checked = false; if (codecH264) codecH264.checked = false;
localStorage.setItem(codecPrefKey, "0"); sharedSettingsSaver.scheduleSave();
console.warn("rd: falling back to jpeg codec", reasonText); console.warn("rd: falling back to jpeg codec", reasonText);
const q = Number(qualitySlider?.value) || 90; const q = Number(qualitySlider?.value) || 90;
setCodecModeLabel("jpeg", "fallback"); setCodecModeLabel("jpeg", "fallback");
@@ -1732,7 +1849,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
} }
ws.addEventListener("message", function (ev) { function onWsMessage(ev) {
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
const buf = new Uint8Array(ev.data); const buf = new Uint8Array(ev.data);
if (isFramePacket(buf)) { if (isFramePacket(buf)) {
@@ -1798,9 +1915,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
return; return;
} }
}); }
ws.addEventListener("open", function () { function onWsOpen() {
reconnectDelay = 1000;
rdDebug("ws open", { rdDebug("ws open", {
url: ws.url, url: ws.url,
readyState: ws.readyState, readyState: ws.readyState,
@@ -1812,7 +1930,27 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
pushInputToggles(); pushInputToggles();
pushCaptureToggles(); pushCaptureToggles();
clearOfflineTimer(); clearOfflineTimer();
setStreamState("idle", "Stopped"); if (desiredStreaming) {
setStreamState("starting", "Resuming stream");
if (displaySelect && displaySelect.value !== undefined) {
sendCmd("desktop_select_display", {
display: parseInt(displaySelect.value, 10) || 0,
});
}
pushTargetFps();
pushResolution();
const mode = getWebrtcMode();
if (mode === "relayed") {
sendCmd("desktop_start", { webrtc: true });
} else if (mode === "p2p") {
sendCmd("desktop_start", {});
startP2P();
} else {
sendCmd("desktop_start", {});
}
} else {
setStreamState("idle", "Stopped");
}
sendCmd("desktop_record_status", {}); sendCmd("desktop_record_status", {});
fetchClientInfo().then(() => { fetchClientInfo().then(() => {
if (displaySelect && displaySelect.value) { if (displaySelect && displaySelect.value) {
@@ -1822,9 +1960,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
} }
}); });
}); }
ws.addEventListener("close", function (event) { function onWsClose(event) {
rdDebug("ws close", { rdDebug("ws close", {
code: event.code, code: event.code,
reason: event.reason, reason: event.reason,
@@ -1832,22 +1970,28 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
streamState, streamState,
desiredStreaming, desiredStreaming,
}); });
desiredStreaming = false;
disconnectAudio(); disconnectAudio();
destroyVideoDecoder(); destroyVideoDecoder();
stopAllWebrtc(); stopAllWebrtc();
clearRecordingTimer(); if (desiredStreaming) {
serverRecording = null; setStreamState("connecting", "Reconnecting");
setRecordingUi(false); scheduleReconnect();
setStreamState("disconnected", "Disconnected"); } else {
}); clearRecordingTimer();
serverRecording = null;
setRecordingUi(false);
setStreamState("disconnected", "Disconnected");
}
}
ws.addEventListener("error", function (event) { function onWsError(event) {
console.warn("rd: ws error", event); console.warn("rd: ws error", event);
destroyVideoDecoder(); destroyVideoDecoder();
stopAllWebrtc(); stopAllWebrtc();
setStreamState("error", "WebSocket error"); setStreamState("error", "WebSocket error");
}); }
connectWs();
if (!frameWatchTimer) { if (!frameWatchTimer) {
frameWatchTimer = setInterval(() => { frameWatchTimer = setInterval(() => {
@@ -1911,7 +2055,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
} }
lastMoveSentAt = now; lastMoveSentAt = now;
if (ws.bufferedAmount <= inputBackpressureBytes) { if (ws && ws.bufferedAmount <= inputBackpressureBytes) {
sendCmd("mouse_move", sendPoint); sendCmd("mouse_move", sendPoint);
} }
} }
@@ -1966,8 +2110,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
}); });
function stopOnExit() { function stopOnExit() {
sharedSettingsSaver.saveNow();
if (isRecording()) stopRecording(); if (isRecording()) stopRecording();
if (ws.readyState === WebSocket.OPEN && desiredStreaming) { if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) {
desiredStreaming = false; desiredStreaming = false;
sendCmd("desktop_stop", {}); sendCmd("desktop_stop", {});
} }
@@ -0,0 +1,60 @@
export async function loadSharedUiSettings(scope) {
try {
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
credentials: "include",
});
if (!res.ok) return {};
const data = await res.json().catch(() => ({}));
return data && typeof data.settings === "object" && data.settings ? data.settings : {};
} catch (err) {
console.warn(`ui-settings: failed to load ${scope}`, err);
return {};
}
}
export function createSharedUiSettingsSaver(scope, readSettings, delayMs = 350) {
let timer = null;
let lastPayload = "";
async function saveNow() {
if (timer) {
clearTimeout(timer);
timer = null;
}
let settings = {};
try {
settings = readSettings() || {};
} catch (err) {
console.warn(`ui-settings: failed to read ${scope}`, err);
return;
}
const payload = JSON.stringify(settings);
if (payload === lastPayload) return;
lastPayload = payload;
try {
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
method: "PUT",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings }),
});
if (!res.ok) {
lastPayload = "";
}
} catch (err) {
lastPayload = "";
console.warn(`ui-settings: failed to save ${scope}`, err);
}
}
function scheduleSave() {
if (timer) clearTimeout(timer);
timer = setTimeout(saveNow, delayMs);
}
return { scheduleSave, saveNow };
}
+22
View File
@@ -148,6 +148,14 @@
/> />
<span id="qualityValue" class="pill pill-ghost text-slate-100 text-xs min-w-[44px] text-center">95%</span> <span id="qualityValue" class="pill pill-ghost text-slate-100 text-xs min-w-[44px] text-center">95%</span>
</div> </div>
<div class="flex items-center gap-2 mb-2">
<label class="text-sm text-slate-300 w-20" for="webrtcMode">Transport</label>
<select id="webrtcMode" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100" title="Streaming transport. P2P: browser to agent direct, server only relays signaling. Relayed: through MediaMTX.">
<option value="off" selected>Canvas</option>
<option value="p2p">WebRTC P2P</option>
<option value="relayed">WebRTC Relayed</option>
</select>
</div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2 text-sm text-slate-200" title="Prefer H.264 when available; disable to force JPEG"> <label class="flex items-center gap-2 text-sm text-slate-200" title="Prefer H.264 when available; disable to force JPEG">
<input type="checkbox" id="codecH264" checked /> H.264 <input type="checkbox" id="codecH264" checked /> H.264
@@ -209,6 +217,20 @@
height: auto; height: auto;
" "
></canvas> ></canvas>
<video
id="webrtcVideo"
class="bg-slate-950"
autoplay
playsinline
muted
style="
display: none;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
"
></video>
</div> </div>
</div> </div>
<div <div
+52
View File
@@ -1649,6 +1649,58 @@ export function deleteBuildProfileForUser(userId: number, name: string): boolean
return ((result as any)?.changes || 0) > 0; return ((result as any)?.changes || 0) > 0;
} }
export interface SharedUiSettingsRecord {
scope: string;
settingsJson: string;
updatedByUserId: number | null;
updatedAt: number;
}
export function getSharedUiSettings(scope: string): SharedUiSettingsRecord | null {
const row = db
.query<any>(
`SELECT scope, settings_json, updated_by_user_id, updated_at
FROM shared_ui_settings
WHERE scope = ?`,
)
.get(scope);
if (!row) return null;
return {
scope: String(row.scope || ""),
settingsJson: String(row.settings_json || "{}"),
updatedByUserId: row.updated_by_user_id == null ? null : Number(row.updated_by_user_id),
updatedAt: Number(row.updated_at) || 0,
};
}
export function saveSharedUiSettings(
scope: string,
settingsJson: string,
updatedByUserId: number,
): SharedUiSettingsRecord {
const now = Date.now();
db.run(
`INSERT INTO shared_ui_settings (scope, settings_json, updated_by_user_id, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(scope) DO UPDATE SET
settings_json = excluded.settings_json,
updated_by_user_id = excluded.updated_by_user_id,
updated_at = excluded.updated_at`,
scope,
settingsJson,
updatedByUserId,
now,
);
return {
scope,
settingsJson,
updatedByUserId,
updatedAt: now,
};
}
export interface SavedScriptRecord { export interface SavedScriptRecord {
id: string; id: string;
userId: number; userId: number;
+9
View File
@@ -329,6 +329,15 @@ db.run(`
`); `);
db.run(`CREATE INDEX IF NOT EXISTS idx_build_profiles_user_updated ON build_profiles(user_id, updated_at DESC);`); db.run(`CREATE INDEX IF NOT EXISTS idx_build_profiles_user_updated ON build_profiles(user_id, updated_at DESC);`);
db.run(`
CREATE TABLE IF NOT EXISTS shared_ui_settings (
scope TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_by_user_id INTEGER,
updated_at INTEGER NOT NULL
);
`);
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS saved_scripts ( CREATE TABLE IF NOT EXISTS saved_scripts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -2,12 +2,19 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config"; import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db"; import {
getClientMetricsSummary,
getClientMetricsSummaryForUser,
getDatabaseFileSizeBytes,
getSharedUiSettings,
listClients,
saveSharedUiSettings,
} from "../../db";
import { getThumbnailStats } from "../../thumbnails"; import { getThumbnailStats } from "../../thumbnails";
import { getClientCount, getOnlineClients } from "../../clientManager"; import { getClientCount, getOnlineClients } from "../../clientManager";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac"; import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById, getUserInputArchiveEnabled, setUserInputArchiveEnabled } from "../../users"; import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, canUserAccessFeature, getUserById, getUserInputArchiveEnabled, setUserInputArchiveEnabled, type FeatureName } from "../../users";
import { buildClientGraph } from "../client-graph"; import { buildClientGraph } from "../client-graph";
import { runCertbotSetup } from "../certbot-setup"; import { runCertbotSetup } from "../certbot-setup";
import { import {
@@ -227,6 +234,76 @@ function summarizeCpuProfile(profile: any, durationMs: number) {
}; };
} }
const SHARED_UI_SETTING_FEATURES: Record<string, FeatureName> = {
remote_desktop: "remote_desktop",
hvnc: "hvnc",
};
function pickString(value: unknown, allowed: readonly string[]): string | undefined {
if (typeof value !== "string") return undefined;
return allowed.includes(value) ? value : undefined;
}
function pickBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function pickSteppedNumber(
value: unknown,
min: number,
max: number,
step = 1,
): number | undefined {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return undefined;
const clamped = Math.max(min, Math.min(max, Math.round(parsed)));
return Math.round(clamped / step) * step;
}
function assignIfDefined<T extends Record<string, unknown>>(
target: T,
key: string,
value: unknown,
): void {
if (value !== undefined) target[key] = value;
}
function sanitizeSharedUiSettings(scope: string, raw: unknown): Record<string, unknown> {
const input = raw && typeof raw === "object" && !Array.isArray(raw)
? raw as Record<string, unknown>
: {};
const out: Record<string, unknown> = {};
assignIfDefined(out, "display", pickSteppedNumber(input.display, 0, 63));
assignIfDefined(out, "quality", pickSteppedNumber(input.quality, 10, 100, 5));
assignIfDefined(out, "preferH264", pickBoolean(input.preferH264));
assignIfDefined(out, "webrtcMode", pickString(input.webrtcMode, ["off", "p2p", "relayed"]));
assignIfDefined(out, "mouse", pickBoolean(input.mouse));
assignIfDefined(out, "keyboard", pickBoolean(input.keyboard));
assignIfDefined(out, "clipboardSync", pickBoolean(input.clipboardSync));
if (scope === "remote_desktop") {
assignIfDefined(out, "resolution", pickString(input.resolution, ["720", "1080", "1440", "-1"]));
assignIfDefined(out, "targetFps", pickString(input.targetFps, ["30", "60", "90", "120", "144", "165", "240"]));
assignIfDefined(out, "cursor", pickBoolean(input.cursor));
assignIfDefined(out, "duplication", pickBoolean(input.duplication));
assignIfDefined(out, "audio", pickBoolean(input.audio));
assignIfDefined(out, "audioTransport", pickString(input.audioTransport, ["off", "p2p", "relayed"]));
assignIfDefined(out, "smoothing", pickSteppedNumber(input.smoothing, 0, 80, 5));
assignIfDefined(out, "recordMode", pickString(input.recordMode, ["normal", "compact"]));
assignIfDefined(out, "recordFps", pickString(input.recordFps, ["", "3", "5", "10", "15", "30", "60"]));
} else if (scope === "hvnc") {
assignIfDefined(out, "resolution", pickString(input.resolution, ["720", "1080", "1440", "-1"]));
assignIfDefined(out, "dxgi", pickBoolean(input.dxgi));
assignIfDefined(out, "uia", pickBoolean(input.uia));
assignIfDefined(out, "cloneProfile", pickBoolean(input.cloneProfile));
assignIfDefined(out, "cloneLite", pickBoolean(input.cloneLite));
assignIfDefined(out, "killIfRunning", pickBoolean(input.killIfRunning));
}
return out;
}
async function runInspectorCpuProfile(durationMs: number) { async function runInspectorCpuProfile(durationMs: number) {
const inspector = await loadInspectorRuntime(); const inspector = await loadInspectorRuntime();
const Session = (inspector as any).Session; const Session = (inspector as any).Session;
@@ -396,6 +473,67 @@ export async function handleMiscRoutes(
}); });
} }
if (url.pathname.startsWith("/api/ui-settings/")) {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const scope = decodeURIComponent(url.pathname.slice("/api/ui-settings/".length));
const feature = SHARED_UI_SETTING_FEATURES[scope];
if (!feature) {
return Response.json({ error: "Unknown UI settings scope" }, { status: 404 });
}
if (!canUserAccessFeature(user.userId, user.role, feature)) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
if (req.method === "GET") {
const record = getSharedUiSettings(scope);
let settings: Record<string, unknown> = {};
if (record?.settingsJson) {
try {
settings = sanitizeSharedUiSettings(scope, JSON.parse(record.settingsJson));
} catch {
settings = {};
}
}
return Response.json(
{
scope,
settings,
updatedAt: record?.updatedAt || null,
updatedByUserId: record?.updatedByUserId || null,
},
{ headers: deps.CORS_HEADERS },
);
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const settings = sanitizeSharedUiSettings(scope, body?.settings);
const record = saveSharedUiSettings(scope, JSON.stringify(settings), user.userId);
return Response.json(
{
ok: true,
scope,
settings,
updatedAt: record.updatedAt,
updatedByUserId: record.updatedByUserId,
},
{ headers: deps.CORS_HEADERS },
);
}
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
if (url.pathname === "/api/settings/telegram") { if (url.pathname === "/api/settings/telegram") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) { if (!user) {
@@ -24,7 +24,7 @@ export function issueWebrtcPublishToken(clientId: string): string {
return token; return token;
} }
export type WebrtcKind = "desktop" | "webcam" | "audio"; export type WebrtcKind = "desktop" | "hvnc" | "webcam" | "audio";
export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string { export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string {
return `agents/${clientId}/${kind}`; return `agents/${clientId}/${kind}`;
@@ -35,7 +35,7 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
const rest = url.pathname.slice("/api/webrtc/".length); const rest = url.pathname.slice("/api/webrtc/".length);
// /api/webrtc/agents/<clientId>/<kind>/(whip|whep)[/<sessionId>] // /api/webrtc/agents/<clientId>/<kind>/(whip|whep)[/<sessionId>]
const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+\/(?:desktop|webcam|audio))\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/); const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+\/(?:desktop|hvnc|webcam|audio))\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/);
if (!match) return new Response("Not Found", { status: 404 }); if (!match) return new Response("Not Found", { status: 404 });
const [, streamPath, kind, sessionSuffix] = match; const [, streamPath, kind, sessionSuffix] = match;
@@ -941,6 +941,7 @@ export function handleWebSocketClose(
if (!stillViewing) { if (!stillViewing) {
const target = clientManager.getClient(removedClientId); const target = clientManager.getClient(removedClientId);
deps.sendDesktopCommand(target, "desktop_stop", {}); deps.sendDesktopCommand(target, "desktop_stop", {});
deps.sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
deps.rdStreamingState.delete(removedClientId); deps.rdStreamingState.delete(removedClientId);
logger.debug(`[rd] cleaned up state for client ${removedClientId}`); logger.debug(`[rd] cleaned up state for client ${removedClientId}`);
} }
@@ -970,6 +971,7 @@ export function handleWebSocketClose(
} }
if (role === "hvnc_viewer") { if (role === "hvnc_viewer") {
deps.cleanupRdViewerP2P(ws);
let removedClientId = clientId; let removedClientId = clientId;
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) { for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
if (sess.viewer === ws) { if (sess.viewer === ws) {
@@ -984,6 +986,7 @@ export function handleWebSocketClose(
const target = clientManager.getClient(removedClientId); const target = clientManager.getClient(removedClientId);
if (target) { if (target) {
deps.sendHVNCCommand(target, "hvnc_stop", {}); deps.sendHVNCCommand(target, "hvnc_stop", {});
deps.sendHVNCCommand(target, "webrtc_stop", { kind: "hvnc" });
} }
deps.hvncStreamingState.delete(removedClientId); deps.hvncStreamingState.delete(removedClientId);
logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`); logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`);
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from "bun";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { SocketData } from "../sessions/types"; import type { SocketData } from "../sessions/types";
export type P2PKind = "desktop" | "webcam" | "audio"; export type P2PKind = "desktop" | "hvnc" | "webcam" | "audio";
export type P2PSession = { export type P2PSession = {
viewer: ServerWebSocket<SocketData>; viewer: ServerWebSocket<SocketData>;
@@ -122,4 +122,30 @@ describe("remote desktop viewer control", () => {
expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true); expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true);
expect(viewer.sent.length).toBeGreaterThan(0); expect(viewer.sent.length).toBeGreaterThan(0);
}); });
test("reasserts desktop_start when server stream state is stale", () => {
const clientId = `rd-stale-${Date.now().toString(36)}`;
const { agentWs } = createClient(clientId);
const viewer = createMockWs({ clientId });
rdStreamingState.set(clientId, {
isStreaming: true,
display: 0,
quality: 90,
codec: "h264",
duplication: true,
maxHeight: 1080,
maxFps: 120,
lastFps: 1,
lastFrameAt: 0,
startedAt: Date.now() - 5000,
});
handleRemoteDesktopViewerOpen(viewer as any);
handleRemoteDesktopViewerMessage(viewer as any, JSON.stringify({ type: "desktop_start" }));
const commands = agentCommands(agentWs);
expect(commands.filter((msg) => msg.commandType === "desktop_start")).toHaveLength(1);
expect(commands.filter((msg) => msg.commandType === "desktop_request_keyframe")).toHaveLength(0);
expect(rdStreamingState.get(clientId)?.isStreaming).toBe(true);
});
}); });
@@ -170,6 +170,8 @@ export const rdStreamingState = new Map<string, {
maxHeight: number; maxHeight: number;
maxFps: number; maxFps: number;
lastFps: number; lastFps: number;
lastFrameAt: number;
startedAt: number;
}>(); }>();
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>(); const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
const RD_INPUT_TTL_MS = 10_000; const RD_INPUT_TTL_MS = 10_000;
@@ -183,7 +185,18 @@ function pruneRdInputPending(now = Date.now()) {
} }
function defaultRdStreamingState() { function defaultRdStreamingState() {
return { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, maxFps: 120, lastFps: 0 }; return {
isStreaming: false,
display: 0,
quality: 90,
codec: "",
duplication: false,
maxHeight: 0,
maxFps: 120,
lastFps: 0,
lastFrameAt: 0,
startedAt: 0,
};
} }
function clampDesktopFps(value: unknown): number { function clampDesktopFps(value: unknown): number {
@@ -379,10 +392,27 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) }); sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
sendDesktopCommand(target, "desktop_start", {}); sendDesktopCommand(target, "desktop_start", {});
state.isStreaming = true; state.isStreaming = true;
state.startedAt = Date.now();
rdStreamingState.set(clientId, state); rdStreamingState.set(clientId, state);
logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`); logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`);
} else { } else {
logger.warn(`[rd-debug] desktop_start ignored duplicate client=${clientId} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`); const lastFrameAgeMs = state.lastFrameAt ? Date.now() - state.lastFrameAt : Number.POSITIVE_INFINITY;
const startAgeMs = state.startedAt ? Date.now() - state.startedAt : Number.POSITIVE_INFINITY;
if (lastFrameAgeMs > 3000 && startAgeMs > 3000) {
logger.info(`[rd-debug] desktop_start reasserting stale stream client=${clientId} lastFrameAgeMs=${Number.isFinite(lastFrameAgeMs) ? lastFrameAgeMs : -1} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) });
sendDesktopCommand(target, "desktop_start", {});
state.isStreaming = true;
state.startedAt = Date.now();
rdStreamingState.set(clientId, state);
} else {
logger.debug(`[rd-debug] desktop_start already active client=${clientId} lastFrameAgeMs=${lastFrameAgeMs} startAgeMs=${startAgeMs} state=${JSON.stringify(state)} viewers=${sessionManager.getRdSessionsForClient(clientId).length}`);
if (String(state.codec || "").toLowerCase() === "h264") {
sendDesktopCommand(target, "desktop_request_keyframe", {
reason: "viewer_start_existing_stream",
});
}
}
} }
break; break;
case "desktop_stop": { case "desktop_stop": {
@@ -395,10 +425,12 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" }); sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
if (state.isStreaming) { if (state.isStreaming) {
state.isStreaming = false; state.isStreaming = false;
state.startedAt = 0;
state.lastFrameAt = 0;
rdStreamingState.set(clientId, state); rdStreamingState.set(clientId, state);
logger.debug(`[rd] stopped streaming for client ${clientId}`); logger.debug(`[rd] stopped streaming for client ${clientId}`);
} else { } else {
rdStreamingState.set(clientId, { ...state, isStreaming: false }); rdStreamingState.set(clientId, { ...state, isStreaming: false, startedAt: 0, lastFrameAt: 0 });
logger.debug(`[rd] stop requested while not streaming for client ${clientId}`); logger.debug(`[rd] stop requested while not streaming for client ${clientId}`);
} }
} else { } else {
@@ -672,9 +704,11 @@ function handleRemoteDesktopFrame(payload: any) {
const state = rdStreamingState.get(clientId) || defaultRdStreamingState(); const state = rdStreamingState.get(clientId) || defaultRdStreamingState();
if (!state.isStreaming) { if (!state.isStreaming) {
state.isStreaming = true; state.isStreaming = true;
state.startedAt = Date.now();
} }
const frameFps = Number(header?.fps) || 0; const frameFps = Number(header?.fps) || 0;
if (frameFps > 0) state.lastFps = frameFps; if (frameFps > 0) state.lastFps = frameFps;
state.lastFrameAt = Date.now();
rdStreamingState.set(clientId, state); rdStreamingState.set(clientId, state);
const now = Date.now(); const now = Date.now();
const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0; const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0;
@@ -690,6 +724,7 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
if (frameFps > 0) { if (frameFps > 0) {
const state = rdStreamingState.get(clientId) || { ...defaultRdStreamingState(), isStreaming: true }; const state = rdStreamingState.get(clientId) || { ...defaultRdStreamingState(), isStreaming: true };
state.lastFps = frameFps; state.lastFps = frameFps;
state.lastFrameAt = Date.now();
rdStreamingState.set(clientId, state); rdStreamingState.set(clientId, state);
} }
recordRemoteDesktopFrame(clientId, header, bytes); recordRemoteDesktopFrame(clientId, header, bytes);
@@ -703,7 +738,20 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header
return broadcastRemoteDesktopFrame(clientId, bytes, header); return broadcastRemoteDesktopFrame(clientId, bytes, header);
}; };
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>(); type HVNCStreamingState = {
isStreaming: boolean;
display: number;
quality: number;
codec: string;
maxFps: number;
lastFps: number;
};
function defaultHVNCStreamingState(): HVNCStreamingState {
return { isStreaming: false, display: 0, quality: 90, codec: "", maxFps: 120, lastFps: 0 };
}
export const hvncStreamingState = new Map<string, HVNCStreamingState>();
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>(); export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>();
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) { export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
@@ -1031,12 +1079,30 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
return; return;
} }
const state = hvncStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "" }; const state = hvncStreamingState.get(clientId) || defaultHVNCStreamingState();
logger.debug(`[hvnc] inbound viewer msg type=${payload.type} client=${clientId}`); logger.debug(`[hvnc] inbound viewer msg type=${payload.type} client=${clientId}`);
switch (payload.type) { switch (payload.type) {
case "hvnc_start": case "hvnc_start":
if (!state.isStreaming) { if (!state.isStreaming) {
if ((payload as any).webrtc === true) {
const streamPath = webrtcStreamPathFor(clientId, "hvnc");
const token = issueWebrtcPublishToken(clientId);
sendHVNCCommand(target, "webrtc_publish", {
streamPath,
whipPath: `/api/webrtc/${streamPath}/whip`,
token,
kind: "hvnc",
hasVideo: true,
hasAudio: false,
});
safeSendViewer(ws, {
type: "webrtc_ready",
streamPath,
whepPath: `/api/webrtc/${streamPath}/whep`,
});
}
sendHVNCCommand(target, "hvnc_set_fps", { fps: clampDesktopFps(state.maxFps) });
sendHVNCCommand(target, "hvnc_start", { sendHVNCCommand(target, "hvnc_start", {
autoStartExplorer: false, autoStartExplorer: false,
}); });
@@ -1053,6 +1119,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
if (otherHvncViewers.length === 0) { if (otherHvncViewers.length === 0) {
if (state.isStreaming) { if (state.isStreaming) {
sendHVNCCommand(target, "hvnc_stop", {}); sendHVNCCommand(target, "hvnc_stop", {});
sendHVNCCommand(target, "webrtc_stop", { kind: "hvnc" });
state.isStreaming = false; state.isStreaming = false;
hvncStreamingState.set(clientId, state); hvncStreamingState.set(clientId, state);
logger.debug(`[hvnc] stopped streaming for client ${clientId}`); logger.debug(`[hvnc] stopped streaming for client ${clientId}`);
@@ -1086,6 +1153,16 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
} }
break; break;
} }
case "hvnc_set_fps": {
const newMaxFps = clampDesktopFps((payload as any).fps);
if (state.maxFps !== newMaxFps) {
sendHVNCCommand(target, "hvnc_set_fps", { fps: newMaxFps });
state.maxFps = newMaxFps;
hvncStreamingState.set(clientId, state);
logger.debug(`[hvnc] set target fps=${newMaxFps}`);
}
break;
}
case "hvnc_enable_mouse": case "hvnc_enable_mouse":
if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_mouse", { enabled: !!payload.enabled }); if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_mouse", { enabled: !!payload.enabled });
break; break;
@@ -1229,6 +1306,34 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
sendDesktopCommand(target, "clipboard_sync_stop", {}); sendDesktopCommand(target, "clipboard_sync_stop", {});
break; break;
} }
case "webrtc_p2p_offer": {
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
if (!sdp) break;
const sessionId = createP2PSession(ws, clientId, "hvnc");
sendHVNCCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "hvnc", hasVideo: true, hasAudio: false });
break;
}
case "webrtc_p2p_ice": {
const sessionId = getP2PSessionIdForViewer(ws);
if (!sessionId) break;
const candidate = typeof (payload as any).candidate === "string" ? (payload as any).candidate : "";
if (!candidate) break;
sendHVNCCommand(target, "webrtc_p2p_ice", {
sessionId,
kind: "hvnc",
candidate,
sdpMid: typeof (payload as any).sdpMid === "string" ? (payload as any).sdpMid : "",
sdpMLineIndex: Number((payload as any).sdpMLineIndex) || 0,
});
break;
}
case "webrtc_p2p_stop": {
const cleared = clearP2PSessionForViewer(ws);
if (cleared) {
sendHVNCCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId, kind: cleared.kind });
}
break;
}
default: default:
break; break;
} }
@@ -1239,6 +1344,15 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload
} }
(globalThis as any).__hvncBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => { (globalThis as any).__hvncBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
const state = hvncStreamingState.get(clientId) || defaultHVNCStreamingState();
if (!state.isStreaming) {
state.isStreaming = true;
}
const frameFps = Number(header?.fps) || 0;
if (frameFps > 0) {
state.lastFps = frameFps;
}
hvncStreamingState.set(clientId, state);
const buf = buildViewerFrameBuffer(bytes, header); const buf = buildViewerFrameBuffer(bytes, header);
return broadcastFrameToViewers(sessionManager.getHvncSessionsForClient(clientId), buf, header); return broadcastFrameToViewers(sessionManager.getHvncSessionsForClient(clientId), buf, header);
}; };
+10 -10
View File
@@ -276,19 +276,19 @@ Prerequisites:
Development mode (starts server + client): Development mode (starts server + client):
```bat ```bat
start-dev.bat scripts\start-dev.bat
``` ```
Production mode (build + run server executable): Production mode (build + run server executable):
```bat ```bat
start-prod.bat scripts\start-prod.bat
``` ```
Build client binaries (adds client builds to the build queue): Build client binaries (adds client builds to the build queue):
```bat ```bat
build-clients.bat scripts\build-clients.bat
``` ```
### Linux / macOS ### Linux / macOS
@@ -296,26 +296,26 @@ build-clients.bat
Make scripts executable once: Make scripts executable once:
```bash ```bash
chmod +x start-dev.sh start-dev-server.sh start-dev-client.sh start-prod.sh build-prod-package.sh chmod +x scripts/*.sh scripts/*.command
``` ```
Development mode (server in background, client in foreground): Development mode (server in background, client in foreground):
```bash ```bash
./start-dev.sh ./scripts/start-dev.sh
``` ```
Only server, or only client: Only server, or only client:
```bash ```bash
./start-dev.sh server ./scripts/start-dev.sh server
./start-dev.sh client ./scripts/start-dev.sh client
``` ```
Production mode: Production mode:
```bash ```bash
./start-prod.sh ./scripts/start-prod.sh
``` ```
--- ---
@@ -327,7 +327,7 @@ Build a production-ready package where the server can still build client binarie
Windows: Windows:
```bat ```bat
build-prod-package.bat scripts\build-prod-package.bat
``` ```
Output: `release/` Output: `release/`
@@ -335,7 +335,7 @@ Output: `release/`
Linux / macOS: Linux / macOS:
```bash ```bash
./build-prod-package.sh ./scripts/build-prod-package.sh
``` ```
Output: `release/prod-package/` Output: `release/prod-package/`
@@ -3,9 +3,10 @@ setlocal EnableDelayedExpansion
REM Build BackstageCapture DLL for Windows x64 using MSBuild (vcxproj). REM Build BackstageCapture DLL for Windows x64 using MSBuild (vcxproj).
REM Run from VS Developer Command Prompt, or let the script detect VS. REM Run from VS Developer Command Prompt, or let the script detect VS.
set ROOT=%~dp0 set "SCRIPT_DIR=%~dp0"
set PROJ=%ROOT%BackstageCapture\BackstageCapture.vcxproj for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set OUT_DIR=%ROOT%Overlord-Server\dist-clients set "PROJ=%ROOT%BackstageCapture\BackstageCapture.vcxproj"
set "OUT_DIR=%ROOT%Overlord-Server\dist-clients"
set CONFIG=Release set CONFIG=Release
set PLATFORM=x64 set PLATFORM=x64
@@ -10,11 +10,13 @@
# - MinHook source under BackstageCapture/Minhook/ # - MinHook source under BackstageCapture/Minhook/
# #
# If MinHook source is not available, you can pre-build the DLL with MSVC # If MinHook source is not available, you can pre-build the DLL with MSVC
# on Windows using build-backstage-capture-dll.bat and place the output at: # on Windows using scripts\build-backstage-capture-dll.bat and place the output at:
# Overlord-Server/dist-clients/BackstageCapture.x64.dll # Overlord-Server/dist-clients/BackstageCapture.x64.dll
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
# C++ compiler for COM / __uuidof support # C++ compiler for COM / __uuidof support
CXX="${CXX:-x86_64-w64-mingw32-g++}" CXX="${CXX:-x86_64-w64-mingw32-g++}"
@@ -3,9 +3,10 @@ setlocal EnableDelayedExpansion
REM Build BackstageInjection DLL for Windows x64 using MSBuild (vcxproj). REM Build BackstageInjection DLL for Windows x64 using MSBuild (vcxproj).
REM Run from VS Developer Command Prompt, or let the script detect VS. REM Run from VS Developer Command Prompt, or let the script detect VS.
set ROOT=%~dp0 set "SCRIPT_DIR=%~dp0"
set PROJ=%ROOT%BackstageInjection\BackstageInjection.vcxproj for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set OUT_DIR=%ROOT%Overlord-Server\dist-clients set "PROJ=%ROOT%BackstageInjection\BackstageInjection.vcxproj"
set "OUT_DIR=%ROOT%Overlord-Server\dist-clients"
set CONFIG=Release set CONFIG=Release
set PLATFORM=x64 set PLATFORM=x64
@@ -15,11 +15,13 @@
# 3) Run this script. # 3) Run this script.
# #
# If MinHook source is not available, you can pre-build the DLL with MSVC # If MinHook source is not available, you can pre-build the DLL with MSVC
# on Windows using build-backstage-dll.bat and place the output at: # on Windows using scripts\build-backstage-dll.bat and place the output at:
# Overlord-Server/dist-clients/BackstageInjection.x64.dll # Overlord-Server/dist-clients/BackstageInjection.x64.dll
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
CC="${CC:-x86_64-w64-mingw32-gcc}" CC="${CC:-x86_64-w64-mingw32-gcc}"
SRC_DIR="${HVNC_SRC_DIR:-BackstageInjection/src}" SRC_DIR="${HVNC_SRC_DIR:-BackstageInjection/src}"
@@ -1,8 +1,9 @@
@echo off @echo off
setlocal setlocal
set ROOT=%~dp0 set "SCRIPT_DIR=%~dp0"
set CLIENT_DIR=%ROOT%Overlord-Client for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set OUT_DIR=%ROOT%dist-clients set "CLIENT_DIR=%ROOT%Overlord-Client"
set "OUT_DIR=%ROOT%dist-clients"
if not defined ENABLE_PERSISTENCE set ENABLE_PERSISTENCE=false if not defined ENABLE_PERSISTENCE set ENABLE_PERSISTENCE=false
if not defined OBFUSCATE set OBFUSCATE=false if not defined OBFUSCATE set OBFUSCATE=false
@@ -228,6 +229,8 @@ goto :eof
:err :err
echo Build failed. See errors above. echo Build failed. See errors above.
popd >nul 2>&1
endlocal
exit /b 1 exit /b 1
:eof :eof
@@ -1,20 +1,32 @@
@echo off @echo off
setlocal
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
echo === Building Overlord Desktop (Tauri) === echo === Building Overlord Desktop (Tauri) ===
cd /d "%~dp0Overlord-Desktop" pushd "%ROOT%Overlord-Desktop"
where bun >nul 2>&1 where bun >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo error: bun is required ^(https://bun.sh^) echo error: bun is required ^(https://bun.sh^)
exit /b 1 goto :err
) )
where cargo >nul 2>&1 where cargo >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo error: rust toolchain is required ^(https://rustup.rs^) echo error: rust toolchain is required ^(https://rustup.rs^)
exit /b 1 goto :err
) )
call bun install || exit /b 1 call bun install || goto :err
call bun run vendor || exit /b 1 call bun run vendor || goto :err
call bun run build:win || exit /b 1 call bun run build:win || goto :err
echo === Done — bundle output: Overlord-Desktop\src-tauri\target\release\bundle\ === echo === Done — bundle output: Overlord-Desktop\src-tauri\target\release\bundle\ ===
popd
pause pause
endlocal
exit /b 0
:err
popd >nul 2>&1
endlocal
exit /b 1
@@ -1,7 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
echo "=== Building Overlord Desktop (Tauri) ===" echo "=== Building Overlord Desktop (Tauri) ==="
cd "$(dirname "$0")/Overlord-Desktop" cd "$ROOT/Overlord-Desktop"
if ! command -v bun >/dev/null 2>&1; then if ! command -v bun >/dev/null 2>&1; then
echo "error: bun is required (https://bun.sh)" >&2 echo "error: bun is required (https://bun.sh)" >&2
@@ -1,6 +1,7 @@
@echo off @echo off
setlocal EnableExtensions setlocal EnableExtensions
set "ROOT=%~dp0" set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set "SERVER_SRC=%ROOT%Overlord-Server" set "SERVER_SRC=%ROOT%Overlord-Server"
set "CLIENT_SRC=%ROOT%Overlord-Client" set "CLIENT_SRC=%ROOT%Overlord-Client"
set "RELEASE_DIR=%ROOT%release" set "RELEASE_DIR=%ROOT%release"
@@ -122,13 +123,17 @@ echo Run this from the package folder:
echo start-prod-release.bat echo start-prod-release.bat
echo Or on Linux: echo Or on Linux:
echo chmod +x start-prod-release.sh overlord-server-linux-x64 ^&^& ./start-prod-release.sh echo chmod +x start-prod-release.sh overlord-server-linux-x64 ^&^& ./start-prod-release.sh
endlocal
exit /b 0 exit /b 0
:robocopy_err :robocopy_err
echo [error] Copy operation failed ^(robocopy exit code %errorlevel%^) echo [error] Copy operation failed ^(robocopy exit code %errorlevel%^)
popd >nul 2>&1
endlocal
exit /b 1 exit /b 1
:err :err
popd >nul 2>&1 popd >nul 2>&1
echo [error] Build/package failed. echo [error] Build/package failed.
endlocal
exit /b 1 exit /b 1
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SERVER_SRC="$ROOT/Overlord-Server" SERVER_SRC="$ROOT/Overlord-Server"
CLIENT_SRC="$ROOT/Overlord-Client" CLIENT_SRC="$ROOT/Overlord-Client"
DIST_CLIENTS_SRC="$ROOT/dist-clients" DIST_CLIENTS_SRC="$ROOT/dist-clients"
@@ -4,7 +4,10 @@ REM Requires OpenSSL to be installed and in PATH
setlocal EnableDelayedExpansion setlocal EnableDelayedExpansion
set CERT_DIR=.\certs set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set "CERT_DIR=%ROOT%certs"
set DAYS=3650 set DAYS=3650
set COUNTRY=US set COUNTRY=US
set STATE=State set STATE=State
@@ -4,7 +4,10 @@
set -e set -e
CERT_DIR="./certs" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
CERT_DIR="$ROOT/certs"
DAYS=3650 # 10 years DAYS=3650 # 10 years
COUNTRY="US" COUNTRY="US"
STATE="State" STATE="State"
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -eu set -eu
ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
CLIENT_DIR="$ROOT/Overlord-Client" CLIENT_DIR="$ROOT/Overlord-Client"
DIST_DIR="$ROOT/dist-clients" DIST_DIR="$ROOT/dist-clients"
GO_BIN="${GO_BIN:-go}" GO_BIN="${GO_BIN:-go}"
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -eu set -eu
ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SERVER_DIR="$ROOT/Overlord-Server" SERVER_DIR="$ROOT/Overlord-Server"
BUN_BIN="${BUN_BIN:-bun}" BUN_BIN="${BUN_BIN:-bun}"
+36
View File
@@ -0,0 +1,36 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
if not defined NO_PRINTING set NO_PRINTING=false
echo === Ensuring server deps (Bun) ===
pushd "%ROOT%Overlord-Server"
echo [server] bun install...
call bun install
popd
echo === Ensuring client dependencies (Go) ===
pushd "%ROOT%Overlord-Client"
if exist go.mod (
echo [client] go mod tidy...
go mod tidy
)
popd
echo === Launching windows ===
rem Bind server to all interfaces for remote access
set HOST=0.0.0.0
set PORT=5173
set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only
set OVERLORD_DISABLE_AGENT_AUTH=true
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"
timeout /t 3 /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 OVERLORD_CAPTURE_METRICS=true && 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.
endlocal
+6 -5
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ROOT="$(cd -- "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "$0")" && pwd)"
SERVER_SCRIPT="$ROOT/start-dev-server.sh" ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
CLIENT_SCRIPT="$ROOT/start-dev-client.sh" SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh"
CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh"
quote() { quote() {
printf "%q" "$1" printf "%q" "$1"
@@ -23,12 +24,12 @@ trap pause_for_error EXIT
if [ "$(uname -s)" != "Darwin" ]; then if [ "$(uname -s)" != "Darwin" ]; then
echo "[start-dev] start-dev.command is intended for macOS." echo "[start-dev] start-dev.command is intended for macOS."
echo "[start-dev] Use ./start-dev.sh on this platform." echo "[start-dev] Use ./scripts/start-dev.sh on this platform."
exit 1 exit 1
fi fi
if ! command -v osascript >/dev/null 2>&1; then if ! command -v osascript >/dev/null 2>&1; then
echo "[start-dev] osascript was not found. Use ./start-dev.sh from a terminal instead." echo "[start-dev] osascript was not found. Use ./scripts/start-dev.sh from a terminal instead."
exit 1 exit 1
fi fi
+2 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -eu set -eu
ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
# Dispatcher script kept for backwards compatibility. # Dispatcher script kept for backwards compatibility.
# Usage: # Usage:
@@ -8,7 +9,6 @@ ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ./start-dev.sh server # start only server # ./start-dev.sh server # start only server
# ./start-dev.sh client # start only client # ./start-dev.sh client # start only client
SCRIPT_DIR="$ROOT"
SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh" SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh"
CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh" CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh"
+209
View File
@@ -0,0 +1,209 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set "MODE=%~1"
REM Tuned production launcher for small VPS targets.
REM Usage:
REM scripts\start-prod-tuned.bat
REM scripts\start-prod-tuned.bat loadtest
REM scripts\start-prod-tuned.bat loadtest-baseline
REM scripts\start-prod-tuned.bat loadtest-profile
REM
REM Optional env overrides before running:
REM PORT=5173
REM HOST=0.0.0.0
REM OVERLORD_AGENT_TOKEN=change-me
REM OVERLORD_USER=admin
REM OVERLORD_PASS=change-me
REM OVERLORD_TLS_OFFLOAD=false
REM OVERLORD_HEARTBEAT_INTERVAL_MS=30000
REM OVERLORD_HEARTBEAT_SWEEP_TICK_MS=1000
REM OVERLORD_CLIENT_DB_SYNC_MS=15000
REM OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=100
REM OVERLORD_EVENT_LOOP_SAMPLE_MS=100
REM OVERLORD_EVENT_LOOP_HISTORY_SECONDS=60
REM OVERLORD_DASHBOARD_DEBOUNCE_MS=1000
REM OVERLORD_DASHBOARD_EVENT_FLUSH_MS=1000
REM OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT=20
REM OVERLORD_OFFLINE_BATCH_SIZE=250
REM OVERLORD_OFFLINE_BATCH_DELAY_MS=25
REM OVERLORD_LOAD_TEST_PROFILE=realistic
REM OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB=true
REM OVERLORD_THUMBNAILS_DASHBOARD_ENABLED=false
REM MAX_WS_MESSAGE_BYTES_CLIENT=8388608
REM MAX_WS_MESSAGE_BYTES_VIEWER=1048576
if not defined HOST set HOST=0.0.0.0
if not defined PORT set PORT=5173
if not defined LOG_LEVEL set LOG_LEVEL=info
if not defined NODE_ENV set NODE_ENV=production
REM Small-host connection tuning. These are conservative for ~250 mostly-idle
REM agents on a 2 vCPU / 2 GB host.
if not defined OVERLORD_HEARTBEAT_INTERVAL_MS set OVERLORD_HEARTBEAT_INTERVAL_MS=30000
if not defined OVERLORD_HEARTBEAT_SWEEP_TICK_MS set OVERLORD_HEARTBEAT_SWEEP_TICK_MS=1000
if not defined OVERLORD_CLIENT_DB_SYNC_MS set OVERLORD_CLIENT_DB_SYNC_MS=15000
if not defined OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE set OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=100
if not defined OVERLORD_EVENT_LOOP_SAMPLE_MS set OVERLORD_EVENT_LOOP_SAMPLE_MS=100
if not defined OVERLORD_EVENT_LOOP_HISTORY_SECONDS set OVERLORD_EVENT_LOOP_HISTORY_SECONDS=60
if not defined OVERLORD_DASHBOARD_DEBOUNCE_MS set OVERLORD_DASHBOARD_DEBOUNCE_MS=1000
if not defined OVERLORD_DASHBOARD_EVENT_FLUSH_MS set OVERLORD_DASHBOARD_EVENT_FLUSH_MS=1000
if not defined OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT set OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT=20
if not defined OVERLORD_OFFLINE_BATCH_SIZE set OVERLORD_OFFLINE_BATCH_SIZE=250
if not defined OVERLORD_OFFLINE_BATCH_DELAY_MS set OVERLORD_OFFLINE_BATCH_DELAY_MS=25
if not defined OVERLORD_STALE_MS set OVERLORD_STALE_MS=120000
if not defined OVERLORD_DISCONNECT_TIMEOUT_MS set OVERLORD_DISCONNECT_TIMEOUT_MS=10000
if not defined PRUNE_BATCH set PRUNE_BATCH=250
if not defined MAX_WS_MESSAGE_BYTES_CLIENT set MAX_WS_MESSAGE_BYTES_CLIENT=8388608
if not defined MAX_WS_MESSAGE_BYTES_VIEWER set MAX_WS_MESSAGE_BYTES_VIEWER=1048576
if not defined OVERLORD_WS_UPGRADE_RATE_WINDOW_MS set OVERLORD_WS_UPGRADE_RATE_WINDOW_MS=10000
set OVERLORD_PROFILE_SOURCE_MODE=false
REM Keep proxy trust disabled unless the deploy explicitly opts into it.
if not defined OVERLORD_TLS_OFFLOAD set OVERLORD_TLS_OFFLOAD=false
if not defined OVERLORD_TRUST_PROXY set OVERLORD_TRUST_PROXY=false
if not defined OVERLORD_TRUST_PROXY_HEADERS set OVERLORD_TRUST_PROXY_HEADERS=false
if not defined OVERLORD_AUTH_COOKIE_SECURE set OVERLORD_AUTH_COOKIE_SECURE=auto
if /I "%MODE%"=="loadtest" (
if not defined OVERLORD_AGENT_TOKEN (
echo [error] loadtest mode requires OVERLORD_AGENT_TOKEN to be set.
echo Example:
echo set OVERLORD_AGENT_TOKEN=some-long-random-token
echo scripts\start-prod-tuned.bat loadtest
endlocal
exit /b 1
)
set OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS=true
if not defined OVERLORD_LOAD_TEST_PROFILE set OVERLORD_LOAD_TEST_PROFILE=realistic
if not defined OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS set OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS=false
if not defined OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB set OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB=true
if "%OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE%"=="100" set OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=500
if not defined OVERLORD_WS_UPGRADE_RATE_MAX set OVERLORD_WS_UPGRADE_RATE_MAX=2000
if not defined OVERLORD_THUMBNAILS_DASHBOARD_ENABLED set OVERLORD_THUMBNAILS_DASHBOARD_ENABLED=false
) else if /I "%MODE%"=="loadtest-profile" (
if not defined OVERLORD_AGENT_TOKEN (
echo [error] loadtest-profile mode requires OVERLORD_AGENT_TOKEN to be set.
echo Example:
echo set OVERLORD_AGENT_TOKEN=some-long-random-token
echo scripts\start-prod-tuned.bat loadtest-profile
endlocal
exit /b 1
)
set OVERLORD_PROFILE_SOURCE_MODE=true
set OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS=true
if not defined OVERLORD_LOAD_TEST_PROFILE set OVERLORD_LOAD_TEST_PROFILE=realistic
if not defined OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS set OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS=false
if not defined OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB set OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB=true
if "%OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE%"=="100" set OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=500
if not defined OVERLORD_WS_UPGRADE_RATE_MAX set OVERLORD_WS_UPGRADE_RATE_MAX=2000
if not defined OVERLORD_THUMBNAILS_DASHBOARD_ENABLED set OVERLORD_THUMBNAILS_DASHBOARD_ENABLED=false
) else if /I "%MODE%"=="loadtest-baseline" (
if not defined OVERLORD_AGENT_TOKEN (
echo [error] loadtest-baseline mode requires OVERLORD_AGENT_TOKEN to be set.
echo Example:
echo set OVERLORD_AGENT_TOKEN=some-long-random-token
echo scripts\start-prod-tuned.bat loadtest-baseline
endlocal
exit /b 1
)
set OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS=true
if not defined OVERLORD_LOAD_TEST_PROFILE set OVERLORD_LOAD_TEST_PROFILE=baseline
if not defined OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS set OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS=true
if not defined OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB set OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB=true
if "%OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE%"=="100" set OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=500
if not defined OVERLORD_WS_UPGRADE_RATE_MAX set OVERLORD_WS_UPGRADE_RATE_MAX=2000
if not defined OVERLORD_THUMBNAILS_DASHBOARD_ENABLED set OVERLORD_THUMBNAILS_DASHBOARD_ENABLED=false
) else (
set OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS=false
if not defined OVERLORD_WS_UPGRADE_RATE_MAX set OVERLORD_WS_UPGRADE_RATE_MAX=30
)
echo === Overlord tuned production server ===
echo HOST=%HOST%
echo PORT=%PORT%
echo NODE_ENV=%NODE_ENV%
echo LOG_LEVEL=%LOG_LEVEL%
echo OVERLORD_HEARTBEAT_INTERVAL_MS=%OVERLORD_HEARTBEAT_INTERVAL_MS%
echo OVERLORD_HEARTBEAT_SWEEP_TICK_MS=%OVERLORD_HEARTBEAT_SWEEP_TICK_MS%
echo OVERLORD_CLIENT_DB_SYNC_MS=%OVERLORD_CLIENT_DB_SYNC_MS%
echo OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE=%OVERLORD_CLIENT_DB_SYNC_BATCH_SIZE%
echo OVERLORD_EVENT_LOOP_SAMPLE_MS=%OVERLORD_EVENT_LOOP_SAMPLE_MS%
echo OVERLORD_EVENT_LOOP_HISTORY_SECONDS=%OVERLORD_EVENT_LOOP_HISTORY_SECONDS%
echo OVERLORD_DASHBOARD_DEBOUNCE_MS=%OVERLORD_DASHBOARD_DEBOUNCE_MS%
echo OVERLORD_DASHBOARD_EVENT_FLUSH_MS=%OVERLORD_DASHBOARD_EVENT_FLUSH_MS%
echo OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT=%OVERLORD_DASHBOARD_EVENT_INLINE_LIMIT%
echo OVERLORD_OFFLINE_BATCH_SIZE=%OVERLORD_OFFLINE_BATCH_SIZE%
echo OVERLORD_OFFLINE_BATCH_DELAY_MS=%OVERLORD_OFFLINE_BATCH_DELAY_MS%
echo MAX_WS_MESSAGE_BYTES_CLIENT=%MAX_WS_MESSAGE_BYTES_CLIENT%
echo MAX_WS_MESSAGE_BYTES_VIEWER=%MAX_WS_MESSAGE_BYTES_VIEWER%
echo OVERLORD_WS_UPGRADE_RATE_MAX=%OVERLORD_WS_UPGRADE_RATE_MAX%
echo OVERLORD_WS_UPGRADE_RATE_WINDOW_MS=%OVERLORD_WS_UPGRADE_RATE_WINDOW_MS%
echo OVERLORD_TLS_OFFLOAD=%OVERLORD_TLS_OFFLOAD%
echo OVERLORD_TRUST_PROXY=%OVERLORD_TRUST_PROXY%
echo OVERLORD_TRUST_PROXY_HEADERS=%OVERLORD_TRUST_PROXY_HEADERS%
echo OVERLORD_AUTH_COOKIE_SECURE=%OVERLORD_AUTH_COOKIE_SECURE%
echo OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS=%OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS%
echo OVERLORD_LOAD_TEST_PROFILE=%OVERLORD_LOAD_TEST_PROFILE%
echo OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS=%OVERLORD_LOAD_TEST_SKIP_SIDE_EFFECTS%
echo OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB=%OVERLORD_LOAD_TEST_DEFER_SYNTHETIC_DB%
echo OVERLORD_THUMBNAILS_DASHBOARD_ENABLED=%OVERLORD_THUMBNAILS_DASHBOARD_ENABLED%
echo OVERLORD_PROFILE_SOURCE_MODE=%OVERLORD_PROFILE_SOURCE_MODE%
echo.
if /I "%OVERLORD_LOAD_TEST_ALLOW_UNSIGNED_AGENTS%"=="true" (
echo [warning] Synthetic unsigned load-test agents are enabled.
echo [warning] Do not use this mode for public production traffic.
echo.
)
pushd "%ROOT%Overlord-Server"
echo [build] installing deps...
call bun install
if errorlevel 1 goto :err
if /I "%OVERLORD_PROFILE_SOURCE_MODE%"=="true" (
echo [build] building server assets for source-profile mode...
call bun run build:css
if errorlevel 1 goto :err
call bun run vendor
if errorlevel 1 goto :err
) else (
echo [build] building server assets and bundle...
call bun run build
if errorlevel 1 goto :err
)
if /I "%OVERLORD_PROFILE_SOURCE_MODE%"=="true" (
echo [build] skipping compiled executable so profiler keeps source function names.
) else (
echo [build] compiling Windows production executable...
call bun run build:prod:win
if errorlevel 1 goto :err
)
echo [build] copying Overlord-Client source for runtime builds...
robocopy "%ROOT%Overlord-Client" "%ROOT%Overlord-Server\dist\Overlord-Client" /E /XD build .git .vscode /NFL /NDL /NJH /NJS >nul
if errorlevel 8 goto :err
set "OVERLORD_ROOT=%ROOT%Overlord-Server"
if /I "%OVERLORD_PROFILE_SOURCE_MODE%"=="true" (
echo [server] starting source-profile server with Bun...
call bun src/index.ts
) else (
echo [server] starting compiled production server...
call "%ROOT%Overlord-Server\dist\overlord-server.exe"
)
popd
echo Server stopped.
endlocal
exit /b 0
:err
popd >nul 2>&1
echo [error] Production start failed.
endlocal
exit /b 1
+3 -2
View File
@@ -1,6 +1,7 @@
@echo off @echo off
setlocal setlocal
set ROOT=%~dp0 set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
REM Simple production launcher for Overlord server. REM Simple production launcher for Overlord server.
REM Builds the Tailwind CSS and server bundle, then runs the compiled executable. REM Builds the Tailwind CSS and server bundle, then runs the compiled executable.
@@ -45,7 +46,7 @@ if defined PORT (
echo [server] starting on default port... echo [server] starting on default port...
) )
echo [server] running compiled executable... echo [server] running compiled executable...
set OVERLORD_ROOT=%ROOT%Overlord-Server set "OVERLORD_ROOT=%ROOT%Overlord-Server"
call "%ROOT%Overlord-Server\dist\overlord-server.exe" call "%ROOT%Overlord-Server\dist\overlord-server.exe"
popd popd
+2 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SERVER_DIR="$ROOT/Overlord-Server" SERVER_DIR="$ROOT/Overlord-Server"
BUN_BIN="${BUN_BIN:-bun}" BUN_BIN="${BUN_BIN:-bun}"
-35
View File
@@ -1,35 +0,0 @@
@echo off
setlocal
set ROOT=%~dp0
if not defined NO_PRINTING set NO_PRINTING=false
echo === Ensuring server deps (Bun) ===
pushd "%ROOT%Overlord-Server"
echo [server] bun install...
call bun install
popd
echo === Ensuring client dependencies (Go) ===
pushd "%ROOT%Overlord-Client"
if exist go.mod (
echo [client] go mod tidy...
go mod tidy
)
popd
echo === Launching windows ===
rem Bind server to all interfaces for remote access
set HOST=0.0.0.0
set PORT=5173
set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only
set OVERLORD_DISABLE_AGENT_AUTH=true
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"
timeout /t 3 /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 OVERLORD_CAPTURE_METRICS=true && 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.
endlocal