@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.
@@ -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" dial sends a Mbps bitrate (2/4/8/16/32 — same value
691
- // the machine view uses as its H.264 ceiling): it drives the media-track
692
- // encoder bitrate (userBitrateKbps) and, for the stateless JPEG fallback
693
- // where bitrate is meaningless, a derived JPEG quality (userQuality).
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
- log?.info(`[gateway] sandbox-screen-open channelId=${channelId} botId=${botId} (cdp${supportsRtc ? '+rtc' : ''}${expectE2E ? '+e2e' : ''})`);
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
- const ffmpegCmd = await probeFfmpeg(ffmpegCandidates, dockerEnvLocal);
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.cdp?.handleBinaryInput(pt.subarray(1));
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.cdp?.handleInput(m);
796
+ entry.input?.handleInput(m);
779
797
  else if (m.type === 'viewport-size')
780
- entry.cdp?.resize(Number(m.width), Number(m.height));
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.cdp?.handleBinaryInput(buf);
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.cdp?.handleInput(msg);
826
+ entry.input?.handleInput(msg);
809
827
  else if (msg.type === 'viewport-size')
810
- entry.cdp?.resize(Number(msg.width), Number(msg.height));
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 (2/4/8/16/32). Drive the H.264 media-track bitrate
823
- // directly, and live-retarget a running encoder. The same pick maps
824
- // to a JPEG quality for the stateless fallback transport (bitrate is
825
- // meaningless there) — monotonic 2→55 … 32→95.
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 (isFinite(mbps) && mbps > 0) {
828
- userBitrateKbps = Math.max(300, Math.min(40000, Math.round(mbps * 1000)));
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
- transcoder?.updateBitrate(userBitrateKbps);
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 (isFinite(f) && f > 0)
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: 25,
881
- // Honor a quality pick made before the media track came up.
882
- bitrateKbps: userBitrateKbps ?? 1200,
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
- releaseCdpScreencast(cdpHost, cdpPort, channelId);
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
- // Attach to the (shared, per-browser) CDP screencast — one capture loop
959
- // no matter how many viewers are watching this sandbox.
960
- acquireCdpScreencast({
961
- host: cdpHost,
962
- port: cdpPort,
963
- minIntervalMs: 55,
964
- quality: 40,
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
- // JPEG over the relay/DC. With E2E negotiated, encrypt with the
987
- // viewer's key; until it arrives, WITHHOLD (never leak a plaintext
988
- // frame to the server). Frames are stateless, so the first one just
989
- // shows a beat later.
990
- if (expectE2E) {
991
- if (!e2eKey)
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
- sendToViewer(encryptScreenPayload(e2eKey, jpeg));
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
- sendToViewer(jpeg);
997
- },
998
- // Frames carry no pointer (headless screenshots have no OS cursor), so
999
- // stream the page's pointer position — the viewer draws the arrow. The
1000
- // shape (CSS cursor under the pointer) gives the hover affordance.
1001
- onCursor: (x, y, click, shape) => {
1002
- const msg = { type: 'cursor', x, y };
1003
- if (click)
1004
- msg.click = true;
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);