@ozaiya/openclaw-channel 0.10.44 → 0.10.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/gateway.js +208 -109
- package/dist/src/gateway.js.map +1 -1
- package/dist/src/sandboxScreenCdp.d.ts +10 -0
- package/dist/src/sandboxScreenCdp.js +129 -31
- package/dist/src/sandboxScreenCdp.js.map +1 -1
- package/dist/src/sandboxScreenDesktop.d.ts +100 -0
- package/dist/src/sandboxScreenDesktop.js +371 -0
- package/dist/src/sandboxScreenDesktop.js.map +1 -0
- package/dist/src/sandboxScreenH264.d.ts +40 -0
- package/dist/src/sandboxScreenH264.js +155 -59
- package/dist/src/sandboxScreenH264.js.map +1 -1
- package/dist/src/sandboxScreenRtc.d.ts +3 -0
- package/dist/src/sandboxScreenRtc.js +37 -3
- package/dist/src/sandboxScreenRtc.js.map +1 -1
- package/package.json +1 -1
package/dist/src/gateway.js
CHANGED
|
@@ -28,7 +28,8 @@ import * as desktop from "./desktopContainer.js";
|
|
|
28
28
|
import { createSandboxFileSession, handleSandboxFileMessage } from "./sandboxFileTransfer.js";
|
|
29
29
|
import { acquireCdpScreencast, releaseCdpScreencast } from "./sandboxScreenCdp.js";
|
|
30
30
|
import { createSandboxRtcSession } from "./sandboxScreenRtc.js";
|
|
31
|
-
import { probeFfmpeg, createH264Transcoder } from "./sandboxScreenH264.js";
|
|
31
|
+
import { probeFfmpeg, createH264Transcoder, createBitrateController } from "./sandboxScreenH264.js";
|
|
32
|
+
import { probeDesktopSource, createDesktopVideo, createDesktopInput } from "./sandboxScreenDesktop.js";
|
|
32
33
|
import { unsealScreenKey, encryptScreenPayload, decryptScreenPayload, E2E_INPUT_MARKER } from "./sandboxScreenE2E.js";
|
|
33
34
|
import { readSandboxIdentity } from "./sandboxIdentity.js";
|
|
34
35
|
import { WeChatMonitorSession, getMonitor, getAllMonitors } from "./wechatMonitor.js";
|
|
@@ -46,6 +47,11 @@ async function ensureSodium() {
|
|
|
46
47
|
const DEFAULT_API_BASE_URL = "https://api.ozaiya.com";
|
|
47
48
|
const GATEWAY_POLL_INTERVAL_MS = 30_000;
|
|
48
49
|
const DEDUP_MAX_SIZE = 2000;
|
|
50
|
+
// Bot computer view: the media-track bitrate ceiling when the viewer hasn't
|
|
51
|
+
// picked an Image Quality (and the one an "Auto" pick restores), and the
|
|
52
|
+
// encoder frame rate.
|
|
53
|
+
const SCREEN_DEFAULT_MAX_KBPS = 2500;
|
|
54
|
+
const SCREEN_DEFAULT_FPS = 25;
|
|
49
55
|
// Module-level dedup set shared across ALL startGatewayMode instances.
|
|
50
56
|
// When OpenClaw hot-reloads the channel config, a new startGatewayMode may start
|
|
51
57
|
// before the old Socket.io connection fully disconnects, resulting in two concurrent
|
|
@@ -583,7 +589,7 @@ export async function startGatewayMode(options) {
|
|
|
583
589
|
// DataChannel (P2P, or TURN via coturn) and move frames + input onto it —
|
|
584
590
|
// skipping the double-proxied relay, which is the dominant latency. The
|
|
585
591
|
// relay stays connected as signaling pipe + automatic fallback.
|
|
586
|
-
const { channelId, botId, supportsRtc, supportsE2E, iceServers, requestVnc } = payload.params;
|
|
592
|
+
const { channelId, botId, supportsRtc, supportsE2E, iceServers, requestVnc, requestDesktop } = payload.params;
|
|
587
593
|
// Full-desktop noVNC tier (the bot's screenMode='vnc' → server &vnc=1 → requestVnc). Instead
|
|
588
594
|
// of the CDP screencast, raw-pipe the sandbox-browser's websockify (x11vnc on Xvfb :1)
|
|
589
595
|
// straight to the relay — exactly like desktop-screen-open. The viewer runs noVNC/RFB and
|
|
@@ -687,15 +693,26 @@ export async function startGatewayMode(options) {
|
|
|
687
693
|
const expectE2E = !!supportsE2E && botPrivateKeysById.has(botId);
|
|
688
694
|
let e2eKey = null;
|
|
689
695
|
// Viewer control-panel overrides (null ⇒ use the transport-tier default).
|
|
690
|
-
// The "Image Quality"
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
// minInterval = ms between captures (frame-rate cap).
|
|
696
|
+
// The "Image Quality" pick sends a Mbps bitrate: it is the media-track
|
|
697
|
+
// encoder's bitrate ceiling and, for the JPEG transports where bitrate is
|
|
698
|
+
// meaningless, a derived JPEG quality (userQuality). minInterval = ms
|
|
699
|
+
// between captures (frame-rate cap). Value 0 = "Auto" (clear the pick).
|
|
695
700
|
let userQuality = null;
|
|
696
|
-
let userBitrateKbps = null;
|
|
697
701
|
let userMinIntervalMs = null;
|
|
698
|
-
|
|
702
|
+
// Desktop source (viewer asked with &desktop=1 — screenMode='vnc' bots):
|
|
703
|
+
// the whole X display + real X11 input, served by the browser
|
|
704
|
+
// container's control server. Compose mode only (host-mode sandboxes
|
|
705
|
+
// don't run it). Unavailable ⇒ the page (CDP) source, and the viewer is
|
|
706
|
+
// told so it can fall back to its noVNC pipe.
|
|
707
|
+
const controlHost = process.env.NOVNC_HOST || '';
|
|
708
|
+
const controlPort = Number(process.env.RECORD_CONTROL_PORT || '8091');
|
|
709
|
+
const desktopInfo = requestDesktop && desktop.isInContainer && controlHost
|
|
710
|
+
? await probeDesktopSource(controlHost, controlPort)
|
|
711
|
+
: null;
|
|
712
|
+
const useDesktop = !!desktopInfo;
|
|
713
|
+
if (requestDesktop && !useDesktop)
|
|
714
|
+
log?.warn(`[gateway] sandbox-screen: desktop source unavailable channelId=${channelId} — page source`);
|
|
715
|
+
log?.info(`[gateway] sandbox-screen-open channelId=${channelId} botId=${botId} (${useDesktop ? `desktop ${desktopInfo.width}x${desktopInfo.height}` : 'cdp'}${supportsRtc ? '+rtc' : ''}${expectE2E ? '+e2e' : ''})`);
|
|
699
716
|
// Resolve the browser container's CDP host:port (the socat-proxied DevTools
|
|
700
717
|
// endpoint). In-container: NOVNC_HOST + CDP port. On host: the mapped 9222.
|
|
701
718
|
let cdpHost = process.env.NOVNC_HOST || 'localhost';
|
|
@@ -735,7 +752,8 @@ export async function startGatewayMode(options) {
|
|
|
735
752
|
if (!desktop.isInContainer && browserContainerId) {
|
|
736
753
|
ffmpegCandidates.push(['docker', 'exec', '-i', browserContainerId, 'ffmpeg']);
|
|
737
754
|
}
|
|
738
|
-
|
|
755
|
+
// (The desktop source encodes inside the browser container — no local ffmpeg needed.)
|
|
756
|
+
const ffmpegCmd = useDesktop ? null : await probeFfmpeg(ffmpegCandidates, dockerEnvLocal);
|
|
739
757
|
if (ffmpegCmd)
|
|
740
758
|
log?.info(`[gateway] sandbox-screen: H.264 encoder available (${ffmpegCmd[0]})`);
|
|
741
759
|
// Open the relay WS back to the server.
|
|
@@ -751,7 +769,7 @@ export async function startGatewayMode(options) {
|
|
|
751
769
|
ack({ ok: false, error: `Failed to connect relay WS: ${String(err)}` });
|
|
752
770
|
return;
|
|
753
771
|
}
|
|
754
|
-
const entry = { relayWs, cdpKey: { host: cdpHost, port: cdpPort } };
|
|
772
|
+
const entry = { relayWs, cdpKey: useDesktop ? undefined : { host: cdpHost, port: cdpPort } };
|
|
755
773
|
screenConnections.set(channelId, entry);
|
|
756
774
|
let relayOpen = false;
|
|
757
775
|
const earlyFrames = [];
|
|
@@ -770,20 +788,20 @@ export async function startGatewayMode(options) {
|
|
|
770
788
|
if (!pt || pt.length < 1)
|
|
771
789
|
return;
|
|
772
790
|
if (pt[0] === 0)
|
|
773
|
-
entry.
|
|
791
|
+
entry.input?.handleBinaryInput(pt.subarray(1));
|
|
774
792
|
else {
|
|
775
793
|
try {
|
|
776
794
|
const m = JSON.parse(pt.subarray(1).toString());
|
|
777
795
|
if (m.type === 'input')
|
|
778
|
-
entry.
|
|
796
|
+
entry.input?.handleInput(m);
|
|
779
797
|
else if (m.type === 'viewport-size')
|
|
780
|
-
entry.
|
|
798
|
+
entry.input?.resize(Number(m.width), Number(m.height));
|
|
781
799
|
}
|
|
782
800
|
catch { /* ignore */ }
|
|
783
801
|
}
|
|
784
802
|
return;
|
|
785
803
|
}
|
|
786
|
-
entry.
|
|
804
|
+
entry.input?.handleBinaryInput(buf);
|
|
787
805
|
return;
|
|
788
806
|
}
|
|
789
807
|
try {
|
|
@@ -805,9 +823,9 @@ export async function startGatewayMode(options) {
|
|
|
805
823
|
return;
|
|
806
824
|
}
|
|
807
825
|
if (msg.type === 'input')
|
|
808
|
-
entry.
|
|
826
|
+
entry.input?.handleInput(msg);
|
|
809
827
|
else if (msg.type === 'viewport-size')
|
|
810
|
-
entry.
|
|
828
|
+
entry.input?.resize(Number(msg.width), Number(msg.height));
|
|
811
829
|
// The viewer pings every second for its latency indicator — answer on
|
|
812
830
|
// the active transport (silently dropped before, which left the
|
|
813
831
|
// latency stat permanently empty on the bot view).
|
|
@@ -819,21 +837,32 @@ export async function startGatewayMode(options) {
|
|
|
819
837
|
// no-op — see cdp.resize: we capture the agent's native viewport.)
|
|
820
838
|
else if (msg.type === 'display-settings') {
|
|
821
839
|
if (msg.setting === 'quality') {
|
|
822
|
-
// value is Mbps
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
840
|
+
// value is Mbps. Drive the H.264 media-track bitrate directly, and
|
|
841
|
+
// live-retarget a running encoder. The same pick maps to a JPEG
|
|
842
|
+
// quality for the JPEG transports (bitrate is meaningless there) —
|
|
843
|
+
// monotonic 1→45, 2.5→58, 5→68 … 32→95.
|
|
826
844
|
const mbps = Number(msg.value);
|
|
827
|
-
if (
|
|
828
|
-
|
|
845
|
+
if (mbps === 0) {
|
|
846
|
+
userQuality = null;
|
|
847
|
+
bitrate.setCeiling(SCREEN_DEFAULT_MAX_KBPS);
|
|
848
|
+
}
|
|
849
|
+
else if (isFinite(mbps) && mbps > 0) {
|
|
829
850
|
userQuality = Math.max(40, Math.min(95, Math.round(45 + Math.log2(mbps) * 10)));
|
|
830
|
-
|
|
851
|
+
// The pick is the adaptation ceiling; it jumps there and
|
|
852
|
+
// backs off from it only on loss.
|
|
853
|
+
bitrate.setCeiling(Math.max(300, Math.min(40000, Math.round(mbps * 1000))));
|
|
831
854
|
}
|
|
832
855
|
}
|
|
833
856
|
else if (msg.setting === 'frameRate') {
|
|
834
857
|
const f = Number(msg.value);
|
|
835
|
-
if (
|
|
858
|
+
if (f === 0) {
|
|
859
|
+
userMinIntervalMs = null;
|
|
860
|
+
desktopVideo?.setFps(SCREEN_DEFAULT_FPS);
|
|
861
|
+
}
|
|
862
|
+
else if (isFinite(f) && f > 0) {
|
|
836
863
|
userMinIntervalMs = Math.round(1000 / Math.max(1, Math.min(30, f)));
|
|
864
|
+
desktopVideo?.setFps(f);
|
|
865
|
+
}
|
|
837
866
|
}
|
|
838
867
|
}
|
|
839
868
|
}
|
|
@@ -847,6 +876,17 @@ export async function startGatewayMode(options) {
|
|
|
847
876
|
t?.close();
|
|
848
877
|
}
|
|
849
878
|
catch { /* ignore */ } };
|
|
879
|
+
// Desktop source halves (useDesktop only): the x11grab stream and the XTest input.
|
|
880
|
+
let desktopVideo = null;
|
|
881
|
+
let desktopInput = null;
|
|
882
|
+
// Media-track bitrate follows the viewer's RTCP loss reports + PLIs,
|
|
883
|
+
// between a floor and the quality pick (or a default ceiling).
|
|
884
|
+
const bitrate = createBitrateController({
|
|
885
|
+
initialKbps: 1200,
|
|
886
|
+
maxKbps: SCREEN_DEFAULT_MAX_KBPS,
|
|
887
|
+
apply: (kbps) => { transcoder?.updateBitrate(kbps); desktopVideo?.updateBitrate(kbps); },
|
|
888
|
+
log: (m) => log?.info(m),
|
|
889
|
+
});
|
|
850
890
|
if (supportsRtc) {
|
|
851
891
|
// The container NAT is symmetric on Docker-Desktop-Mac, so a missing
|
|
852
892
|
// TURN entry here means the direct tier can't ever connect — make
|
|
@@ -870,16 +910,25 @@ export async function startGatewayMode(options) {
|
|
|
870
910
|
onData: (data) => handleViewerData(data, typeof data !== 'string'),
|
|
871
911
|
onActive: () => log?.info(`[gateway] sandbox-screen: P2P DataChannel active channelId=${channelId} — frames direct, relay on standby`),
|
|
872
912
|
onClosed: () => log?.info(`[gateway] sandbox-screen: P2P closed channelId=${channelId} — frames back on relay`),
|
|
873
|
-
enableVideo: !!ffmpegCmd,
|
|
913
|
+
enableVideo: !!ffmpegCmd || useDesktop,
|
|
874
914
|
onMediaStateChange: (ready) => {
|
|
915
|
+
if (ready)
|
|
916
|
+
bitrate.settle();
|
|
917
|
+
if (useDesktop) {
|
|
918
|
+
// Same x11grab stream, re-dialed as H.264 for the media track
|
|
919
|
+
// or JPEG for the DataChannel/relay.
|
|
920
|
+
log?.info(`[gateway] sandbox-screen: desktop ${ready ? 'H.264 media track up' : 'media track down — JPEG'} channelId=${channelId}`);
|
|
921
|
+
desktopVideo?.setMode(ready ? 'h264' : 'jpeg');
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
875
924
|
if (ready && ffmpegCmd && !transcoder && !transcoderDead) {
|
|
876
925
|
log?.info(`[gateway] sandbox-screen: H.264 media track up channelId=${channelId} — starting encoder`);
|
|
877
926
|
transcoder = createH264Transcoder({
|
|
878
927
|
cmdPrefix: ffmpegCmd,
|
|
879
928
|
env: dockerEnvLocal,
|
|
880
|
-
fps:
|
|
881
|
-
//
|
|
882
|
-
bitrateKbps:
|
|
929
|
+
fps: SCREEN_DEFAULT_FPS,
|
|
930
|
+
// Current adaptive target (includes a quality pick made before the track came up).
|
|
931
|
+
bitrateKbps: bitrate.current(),
|
|
883
932
|
onAccessUnit: (au) => { rtc?.sendVideoFrame(au); },
|
|
884
933
|
onExit: () => { transcoder = null; transcoderDead = true; log?.warn(`[gateway] sandbox-screen: encoder gone channelId=${channelId} — JPEG fallback`); },
|
|
885
934
|
log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
|
|
@@ -891,7 +940,8 @@ export async function startGatewayMode(options) {
|
|
|
891
940
|
},
|
|
892
941
|
// Viewer PLI → force the encoder to emit a keyframe now (debounced
|
|
893
942
|
// inside the transcoder). No transcoder ⇒ no-op (GOP recovers).
|
|
894
|
-
onKeyframeRequest: () => transcoder?.forceKeyframe(),
|
|
943
|
+
onKeyframeRequest: () => { bitrate.onPli(); transcoder?.forceKeyframe(); desktopVideo?.forceKeyframe(); },
|
|
944
|
+
onLossReport: (fraction) => bitrate.onLoss(fraction),
|
|
895
945
|
log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
|
|
896
946
|
});
|
|
897
947
|
}
|
|
@@ -900,10 +950,22 @@ export async function startGatewayMode(options) {
|
|
|
900
950
|
return;
|
|
901
951
|
screenConnections.delete(channelId);
|
|
902
952
|
stopTranscoder();
|
|
953
|
+
try {
|
|
954
|
+
desktopVideo?.close();
|
|
955
|
+
}
|
|
956
|
+
catch { /* ignore */ }
|
|
957
|
+
try {
|
|
958
|
+
desktopInput?.close();
|
|
959
|
+
}
|
|
960
|
+
catch { /* ignore */ }
|
|
961
|
+
desktopVideo = null;
|
|
962
|
+
desktopInput = null;
|
|
903
963
|
// The CDP session is SHARED across viewers of this browser — release
|
|
904
964
|
// our slot; it closes only when the last viewer leaves.
|
|
905
|
-
|
|
965
|
+
if (!useDesktop)
|
|
966
|
+
releaseCdpScreencast(cdpHost, cdpPort, channelId);
|
|
906
967
|
entry.cdp = undefined;
|
|
968
|
+
entry.input = undefined;
|
|
907
969
|
try {
|
|
908
970
|
rtc?.close();
|
|
909
971
|
}
|
|
@@ -955,88 +1017,121 @@ export async function startGatewayMode(options) {
|
|
|
955
1017
|
earlyFrames.push(data);
|
|
956
1018
|
}
|
|
957
1019
|
};
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
|
|
966
|
-
}, channelId, {
|
|
967
|
-
// Transport-adaptive tuning. Relay tier: ~18fps / q40 — capture is only
|
|
968
|
-
// ~38ms, so the bottleneck is per-frame transfer over the (Cloudflare-
|
|
969
|
-
// proxied) relay; smaller frames transfer faster = lower latency. Direct
|
|
970
|
-
// tier (P2P/TURN DataChannel): the proxy hop is gone, so spend the
|
|
971
|
-
// headroom on fidelity + smoothness (~25fps / q60). H.264 tier: the JPEG
|
|
972
|
-
// is only encoder input (bandwidth decouples from its size), so feed it
|
|
973
|
-
// high quality at the encoder's frame rate. The server drops frames a
|
|
974
|
-
// slow viewer can't take (dropFramesOnBackpressure) and the screencast
|
|
975
|
-
// self-throttles on the active transport's buffer, so these stay
|
|
976
|
-
// ceilings. (Shared session: max quality / min interval across viewers.)
|
|
977
|
-
getQuality: () => (userQuality ?? (transcoder && rtc?.isMediaReady() ? 80 : rtc?.isActive() ? 60 : 40)),
|
|
978
|
-
getMinIntervalMs: () => (userMinIntervalMs ?? (transcoder && rtc?.isMediaReady() ? 40 : rtc?.isActive() ? 40 : 55)),
|
|
979
|
-
onFrame: (jpeg) => {
|
|
980
|
-
// H.264 tier: the poll feeds the encoder; the WebRTC media track is
|
|
981
|
-
// already DTLS-E2E, so no app-layer crypto there.
|
|
982
|
-
if (transcoder && rtc?.isMediaReady()) {
|
|
983
|
-
transcoder.feed(jpeg);
|
|
1020
|
+
// A JPEG frame for the DataChannel/relay tiers. With E2E negotiated,
|
|
1021
|
+
// encrypt with the viewer's key; until it arrives, WITHHOLD (never leak a
|
|
1022
|
+
// plaintext frame to the server). Frames are stateless, so the first one
|
|
1023
|
+
// just shows a beat later.
|
|
1024
|
+
const sendJpegFrame = (jpeg) => {
|
|
1025
|
+
if (expectE2E) {
|
|
1026
|
+
if (!e2eKey)
|
|
984
1027
|
return;
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1028
|
+
sendToViewer(encryptScreenPayload(e2eKey, jpeg));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
sendToViewer(jpeg);
|
|
1032
|
+
};
|
|
1033
|
+
if (useDesktop) {
|
|
1034
|
+
desktopVideo = createDesktopVideo({
|
|
1035
|
+
host: controlHost,
|
|
1036
|
+
port: controlPort,
|
|
1037
|
+
mode: 'jpeg', // the media track (if any) comes up later
|
|
1038
|
+
fps: userMinIntervalMs ? Math.round(1000 / userMinIntervalMs) : SCREEN_DEFAULT_FPS,
|
|
1039
|
+
bitrateKbps: bitrate.current(),
|
|
1040
|
+
onAccessUnit: (au) => { rtc?.sendVideoFrame(au); },
|
|
1041
|
+
onJpeg: sendJpegFrame,
|
|
1042
|
+
onExit: () => {
|
|
1043
|
+
// The display can't be streamed after all — tell the viewer (it
|
|
1044
|
+
// falls back to noVNC) and end this session.
|
|
1045
|
+
try {
|
|
1046
|
+
if (relayWs.readyState === 1)
|
|
1047
|
+
relayWs.send(JSON.stringify({ type: 'screen-source', source: 'page' }));
|
|
1048
|
+
}
|
|
1049
|
+
catch { /* ignore */ }
|
|
1050
|
+
cleanupScreen('desktop-stream-failed');
|
|
1051
|
+
},
|
|
1052
|
+
log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
|
|
1053
|
+
});
|
|
1054
|
+
desktopInput = createDesktopInput({ host: controlHost, port: controlPort, log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) } });
|
|
1055
|
+
entry.input = desktopInput;
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
// Attach to the (shared, per-browser) CDP screencast — one capture loop
|
|
1059
|
+
// no matter how many viewers are watching this sandbox.
|
|
1060
|
+
acquireCdpScreencast({
|
|
1061
|
+
host: cdpHost,
|
|
1062
|
+
port: cdpPort,
|
|
1063
|
+
minIntervalMs: 55,
|
|
1064
|
+
quality: 40,
|
|
1065
|
+
log: { info: (m) => log?.info(m), warn: (m) => log?.warn(m) },
|
|
1066
|
+
}, channelId, {
|
|
1067
|
+
// Transport-adaptive tuning. Relay tier: ~18fps / q40 — capture is only
|
|
1068
|
+
// ~38ms, so the bottleneck is per-frame transfer over the (Cloudflare-
|
|
1069
|
+
// proxied) relay; smaller frames transfer faster = lower latency. Direct
|
|
1070
|
+
// tier (P2P/TURN DataChannel): the proxy hop is gone, so spend the
|
|
1071
|
+
// headroom on fidelity + smoothness (~25fps / q60). H.264 tier: the JPEG
|
|
1072
|
+
// is only encoder input (bandwidth decouples from its size), so feed it
|
|
1073
|
+
// high quality at the encoder's frame rate. The server drops frames a
|
|
1074
|
+
// slow viewer can't take (dropFramesOnBackpressure) and the screencast
|
|
1075
|
+
// self-throttles on the active transport's buffer, so these stay
|
|
1076
|
+
// ceilings. (Shared session: max quality / min interval across viewers.)
|
|
1077
|
+
// A quality pick only re-tunes the JPEG transports: on the H.264 tier the
|
|
1078
|
+
// JPEG is encoder input, and a lower one would just blur what the bitrate
|
|
1079
|
+
// ceiling (the pick's real effect there) encodes.
|
|
1080
|
+
getQuality: () => (transcoder && rtc?.isMediaReady() ? 80 : userQuality ?? (rtc?.isActive() ? 60 : 40)),
|
|
1081
|
+
getMinIntervalMs: () => (userMinIntervalMs ?? (transcoder && rtc?.isMediaReady() ? 40 : rtc?.isActive() ? 40 : 55)),
|
|
1082
|
+
onFrame: (jpeg) => {
|
|
1083
|
+
// H.264 tier: the poll feeds the encoder; the WebRTC media track is
|
|
1084
|
+
// already DTLS-E2E, so no app-layer crypto there.
|
|
1085
|
+
if (transcoder && rtc?.isMediaReady()) {
|
|
1086
|
+
transcoder.feed(jpeg);
|
|
992
1087
|
return;
|
|
993
|
-
|
|
1088
|
+
}
|
|
1089
|
+
sendJpegFrame(jpeg);
|
|
1090
|
+
},
|
|
1091
|
+
// Frames carry no pointer (headless screenshots have no OS cursor), so
|
|
1092
|
+
// stream the page's pointer position — the viewer draws the arrow. The
|
|
1093
|
+
// shape (CSS cursor under the pointer) gives the hover affordance.
|
|
1094
|
+
onCursor: (x, y, click, shape) => {
|
|
1095
|
+
const msg = { type: 'cursor', x, y };
|
|
1096
|
+
if (click)
|
|
1097
|
+
msg.click = true;
|
|
1098
|
+
if (shape)
|
|
1099
|
+
msg.shape = shape;
|
|
1100
|
+
sendToViewer(JSON.stringify(msg));
|
|
1101
|
+
},
|
|
1102
|
+
// Text copied inside the sandbox page → viewer clipboard. Clipboard is
|
|
1103
|
+
// sensitive (passwords), so encrypt it under E2E — sent as a base64
|
|
1104
|
+
// `enc` field (text, so the server's frame-drop never touches it). When
|
|
1105
|
+
// E2E is expected but the key hasn't landed, drop rather than leak.
|
|
1106
|
+
onClipboard: (text) => {
|
|
1107
|
+
if (expectE2E) {
|
|
1108
|
+
if (!e2eKey)
|
|
1109
|
+
return;
|
|
1110
|
+
sendToViewer(JSON.stringify({ type: 'clipboard', enc: encryptScreenPayload(e2eKey, Buffer.from(text, 'utf-8')).toString('base64') }));
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
sendToViewer(JSON.stringify({ type: 'clipboard', text }));
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
getBackpressure: () => {
|
|
1117
|
+
if (transcoder && rtc?.isMediaReady())
|
|
1118
|
+
return 0; // encoder drops internally; RTP self-paces
|
|
1119
|
+
return rtc?.isActive() ? rtc.getBufferedAmount() : (relayOpen ? relayWs.bufferedAmount : 0);
|
|
1120
|
+
},
|
|
1121
|
+
}).then((session) => {
|
|
1122
|
+
if (!session) {
|
|
1123
|
+
cleanupScreen('cdp-init-failed');
|
|
994
1124
|
return;
|
|
995
1125
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
if (shape)
|
|
1006
|
-
msg.shape = shape;
|
|
1007
|
-
sendToViewer(JSON.stringify(msg));
|
|
1008
|
-
},
|
|
1009
|
-
// Text copied inside the sandbox page → viewer clipboard. Clipboard is
|
|
1010
|
-
// sensitive (passwords), so encrypt it under E2E — sent as a base64
|
|
1011
|
-
// `enc` field (text, so the server's frame-drop never touches it). When
|
|
1012
|
-
// E2E is expected but the key hasn't landed, drop rather than leak.
|
|
1013
|
-
onClipboard: (text) => {
|
|
1014
|
-
if (expectE2E) {
|
|
1015
|
-
if (!e2eKey)
|
|
1016
|
-
return;
|
|
1017
|
-
sendToViewer(JSON.stringify({ type: 'clipboard', enc: encryptScreenPayload(e2eKey, Buffer.from(text, 'utf-8')).toString('base64') }));
|
|
1018
|
-
}
|
|
1019
|
-
else {
|
|
1020
|
-
sendToViewer(JSON.stringify({ type: 'clipboard', text }));
|
|
1021
|
-
}
|
|
1022
|
-
},
|
|
1023
|
-
getBackpressure: () => {
|
|
1024
|
-
if (transcoder && rtc?.isMediaReady())
|
|
1025
|
-
return 0; // encoder drops internally; RTP self-paces
|
|
1026
|
-
return rtc?.isActive() ? rtc.getBufferedAmount() : (relayOpen ? relayWs.bufferedAmount : 0);
|
|
1027
|
-
},
|
|
1028
|
-
}).then((session) => {
|
|
1029
|
-
if (!session) {
|
|
1030
|
-
cleanupScreen('cdp-init-failed');
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
if (!screenConnections.has(channelId)) {
|
|
1034
|
-
releaseCdpScreencast(cdpHost, cdpPort, channelId);
|
|
1035
|
-
return;
|
|
1036
|
-
} // closed during init
|
|
1037
|
-
entry.cdp = session;
|
|
1038
|
-
log?.info(`[gateway] sandbox-screen: CDP screencast attached channelId=${channelId} (${cdpHost}:${cdpPort})`);
|
|
1039
|
-
}).catch((err) => { log?.warn(`[gateway] sandbox-screen: CDP init error ${String(err)}`); cleanupScreen('cdp-error'); });
|
|
1126
|
+
if (!screenConnections.has(channelId)) {
|
|
1127
|
+
releaseCdpScreencast(cdpHost, cdpPort, channelId);
|
|
1128
|
+
return;
|
|
1129
|
+
} // closed during init
|
|
1130
|
+
entry.cdp = session;
|
|
1131
|
+
entry.input = session;
|
|
1132
|
+
log?.info(`[gateway] sandbox-screen: CDP screencast attached channelId=${channelId} (${cdpHost}:${cdpPort})`);
|
|
1133
|
+
}).catch((err) => { log?.warn(`[gateway] sandbox-screen: CDP init error ${String(err)}`); cleanupScreen('cdp-error'); });
|
|
1134
|
+
}
|
|
1040
1135
|
relayWs.on("open", () => {
|
|
1041
1136
|
try {
|
|
1042
1137
|
relayWs._socket?.setNoDelay?.(true);
|
|
@@ -1044,6 +1139,10 @@ export async function startGatewayMode(options) {
|
|
|
1044
1139
|
catch { /* ignore */ }
|
|
1045
1140
|
log?.info(`[gateway] sandbox-screen: relay WS connected channelId=${channelId}`);
|
|
1046
1141
|
relayOpen = true;
|
|
1142
|
+
// Which source this session streams: 'desktop' (whole X display, X11
|
|
1143
|
+
// input) or 'page' (CDP). A viewer that asked for the desktop and
|
|
1144
|
+
// hears 'page' falls back to its noVNC pipe.
|
|
1145
|
+
relayWs.send(JSON.stringify({ type: 'screen-source', source: useDesktop ? 'desktop' : 'page' }));
|
|
1047
1146
|
for (const f of earlyFrames) {
|
|
1048
1147
|
if (relayWs.readyState === 1)
|
|
1049
1148
|
relayWs.send(f);
|