Skip to content

Commit 0cdd7c4

Browse files
authored
Merge pull request #21 from JingxuanKang/cursor/exclusive-vnc-file-upload-71be
Formal occupied copy + exclusive-VNC local file upload
2 parents 1cc82e9 + f81b9c9 commit 0cdd7c4

15 files changed

Lines changed: 916 additions & 23 deletions

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ services:
6969
PROXY_URL_OVERRIDE: ${PROXY_URL_A:-}
7070
volumes:
7171
- ./data/a:/config
72-
# Chromium DevTools 默认关闭。面板「多人分屏」才会写 ENABLE_CDP /
73-
# /config/.gpc-cdp。调试口不映射宿主机,cdp-fwd 只接受 gateway。
72+
# Exclusive VNC 的文件管道会开本机调试口(回形针走用户电脑选文件)。
73+
# 多人分屏仍关闭。调试口不映射宿主机,cdp-fwd 只接受 gateway。
7474

7575
desktop-b:
7676
<<: *desktop

docker/autostart

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,11 @@ read_cdp() {
3636
fi
3737
}
3838

39+
# Exclusive VNC file pipe: debugger + cdp-fwd stay up so the gateway can
40+
# intercept ChatGPT paperclip. This is not 多人分屏 / tab seats.
3941
sync_cdp_fwd() {
40-
if [ "${CDP_ON}" = "1" ]; then
41-
if ! pgrep -f '/usr/local/bin/gpc-cdp-fwd' >/dev/null 2>&1; then
42-
python3 /usr/local/bin/gpc-cdp-fwd >/tmp/gpc-cdp-fwd.log 2>&1 &
43-
fi
44-
else
45-
pkill -f '/usr/local/bin/gpc-cdp-fwd' >/dev/null 2>&1 || true
42+
if ! pgrep -f '/usr/local/bin/gpc-cdp-fwd' >/dev/null 2>&1; then
43+
python3 /usr/local/bin/gpc-cdp-fwd >/tmp/gpc-cdp-fwd.log 2>&1 &
4644
fi
4745
}
4846
read_proxy
@@ -93,12 +91,13 @@ while true; do
9391
PROXY_FLAG="--proxy-server=${PROXY_URL}"
9492
echo "[autostart] proxy-server=${PROXY_URL}"
9593
fi
96-
CDP_FLAGS=""
94+
# Always on for the exclusive-VNC file pipe. CDP_ON is leftover 多人分屏.
95+
CDP_FLAGS="--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=*"
96+
echo "[autostart] file-pipe :9222"
9797
if [ "${CDP_ON}" = "1" ]; then
98-
CDP_FLAGS="--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=*"
9998
echo "[autostart] cdp=on :9222"
10099
else
101-
echo "[autostart] cdp=off"
100+
echo "[autostart] cdp=off (file-pipe still on)"
102101
fi
103102
# No --kiosk: kiosk + --app blocks extra chatgpt.com tabs. Tab seats use
104103
# Target.createTarget (new window, parked off-screen). Members stream the

gateway/server.mjs

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import httpProxy from "http-proxy";
66
import { createSessionToken, readSession, createLoginLimiter, createSessionStore } from "../lib/auth.mjs";
77
import { parseInstances } from "../lib/instances.mjs";
88
import { createDeskRegistry, provisionDesk, retireDesk } from "../lib/desks.mjs";
9-
import { createDockerClient, ensureDeskContainer, removeDeskContainer } from "../lib/docker.mjs";
9+
import { createDockerClient, deskContainerName, ensureDeskContainer, removeDeskContainer } from "../lib/docker.mjs";
1010
import { createUserStore } from "../lib/users.mjs";
1111
import { createPresence } from "../lib/presence.mjs";
1212
import { createSocketHub, kickLiveSession } from "../lib/kick.mjs";
1313
import { evaluateInDesk, waitForDesk, peekClipboard, isShareUrl, SHARE_CLICK, TAB_CLIP_READ, READ_PROJECT_URL, projectOnboardScript, listSeatProjectLinks, sleep } from "../lib/chrome.mjs";
1414
import { applyDeskProxyLive, applyDeskProxiesLive } from "../lib/proxy.mjs";
1515
import { createSeatRegistry, parseTabSeatCap, publicSeat } from "../lib/seats.mjs";
16-
import { applyDeskCdpLive, attachSeatTarget, closeTarget, createParkedChatGPTTab, evaluateOnTarget, forgetDeskBrowser, targetExists } from "../lib/cdp.mjs";
16+
import { applyDeskCdpLive, attachSeatTarget, closeTarget, createParkedChatGPTTab, deskBrowserWs, evaluateOnTarget, forgetDeskBrowser, listDeskTargets, targetExists } from "../lib/cdp.mjs";
1717
import { createSeatJailRegistry, navigateSeatToUrl, pickNamedProjectHref, projectUrlFromOnboard, seatStartUrl } from "../lib/project-jail.mjs";
1818
import { startSeatScreencast } from "../lib/screencast.mjs";
1919
import { applyTabPastePlan, tabPastePlan } from "../lib/tab-paste.mjs";
20+
import { applyDeskUpload, armFileChooser, createChooserRegistry, FILE_TOO_BIG, FILE_UPLOAD_FAIL } from "../lib/file-chooser.mjs";
21+
import { stageDeskUpload } from "../lib/desk-files.mjs";
2022
import { WebSocketServer } from "ws";
2123

2224
const PORT = Number(process.env.PORT || 8080);
@@ -51,8 +53,12 @@ const liveSockets = createSocketHub();
5153
const seatWss = new WebSocketServer({ noServer: true });
5254
const loginLimiter = createLoginLimiter({ maxFails: 10, windowMs: 15 * 60 * 1000 });
5355
const deskCdpLocks = new Map();
56+
const choosers = createChooserRegistry();
57+
const fileWatches = new Map();
58+
const FILE_BODY_MAX = 16 * 1024 * 1024;
5459
/** Yield so a second /open can create a tab before assist grabs the debugger. */
5560
const ONBOARD_YIELD_MS = 1500;
61+
const FILE_PIPE_WAIT_MS = 15_000;
5662

5763
async function withDeskCdp(id, fn) {
5864
const prev = deskCdpLocks.get(id) || Promise.resolve();
@@ -176,7 +182,130 @@ async function closeSeatTab(seat) {
176182
}
177183
}
178184

185+
function stopFileWatch(userId) {
186+
const watch = fileWatches.get(userId);
187+
fileWatches.delete(userId);
188+
try {
189+
watch?.dispose?.();
190+
} catch {
191+
/* ignore */
192+
}
193+
}
194+
195+
async function findChatGptTargetId(deskId) {
196+
try {
197+
const pages = await listDeskTargets(deskId);
198+
const chat =
199+
pages.find((t) => t.type === "page" && /chatgpt\.com/i.test(t.url || "")) ||
200+
pages.find((t) => t.type === "page");
201+
return chat?.id || "";
202+
} catch {
203+
return "";
204+
}
205+
}
206+
207+
async function ensureExclusiveFilePipe(deskId) {
208+
try {
209+
await deskBrowserWs(deskId);
210+
return true;
211+
} catch {
212+
/* old desktop images start Chromium without a debugger until .gpc-cdp=1 */
213+
}
214+
try {
215+
await applyDeskCdpLive(deskId, true);
216+
} catch {
217+
return false;
218+
}
219+
const t0 = Date.now();
220+
while (Date.now() - t0 < FILE_PIPE_WAIT_MS) {
221+
try {
222+
await deskBrowserWs(deskId);
223+
return true;
224+
} catch {
225+
await sleep(400);
226+
}
227+
}
228+
return false;
229+
}
230+
231+
async function watchExclusiveFileChooser(deskId, user) {
232+
stopFileWatch(user.id);
233+
const ready = await ensureExclusiveFilePipe(deskId);
234+
if (!ready) return;
235+
const targetId = await findChatGptTargetId(deskId);
236+
if (!targetId) return;
237+
const attached = await attachSeatTarget(deskId, targetId);
238+
const armed = await armFileChooser({
239+
send: (method, params, sid) => attached.cdp.send(method, params, sid ?? attached.sessionId),
240+
on: (fn) => attached.cdp.on(fn),
241+
sessionId: attached.sessionId,
242+
onOpened: (info) => {
243+
choosers.set(deskId, user.id, { ...info, targetId });
244+
},
245+
});
246+
fileWatches.set(user.id, {
247+
deskId,
248+
dispose() {
249+
try {
250+
armed.dispose?.();
251+
} catch {
252+
/* ignore */
253+
}
254+
Promise.resolve(attached.release?.()).catch(() => {});
255+
choosers.clear(deskId, user.id);
256+
},
257+
});
258+
}
259+
260+
async function readFileUploadBody(req) {
261+
const chunks = [];
262+
let size = 0;
263+
for await (const c of req) {
264+
size += c.length;
265+
if (size > FILE_BODY_MAX) {
266+
const err = new Error(FILE_TOO_BIG);
267+
err.status = 413;
268+
throw err;
269+
}
270+
chunks.push(c);
271+
}
272+
const raw = Buffer.concat(chunks).toString("utf8");
273+
if (!raw) return {};
274+
try {
275+
return JSON.parse(raw);
276+
} catch {
277+
const err = new Error("无法上传文件");
278+
err.status = 400;
279+
throw err;
280+
}
281+
}
282+
283+
async function handleDeskFileUpload(deskId, user, body) {
284+
const pending = choosers.get(deskId, user.id);
285+
const cancel = !!body.cancel;
286+
let targetId = pending?.targetId || "";
287+
if (!targetId && !cancel && Array.isArray(body.files) && body.files.length) {
288+
targetId = await findChatGptTargetId(deskId);
289+
}
290+
try {
291+
const out = await applyDeskUpload({
292+
files: body.files,
293+
cancel,
294+
drop: body.drop && typeof body.drop === "object" ? body.drop : null,
295+
pending,
296+
targetId,
297+
attach: (tid) => attachSeatTarget(deskId, tid),
298+
stage: (files) => stageDeskUpload(deskId, files, { docker, containerName: deskContainerName(deskId) }),
299+
});
300+
if (out.ok || cancel) choosers.clear(deskId, user.id);
301+
return out;
302+
} catch (e) {
303+
return { ok: false, error: e.message || FILE_UPLOAD_FAIL, status: e.status || 502 };
304+
}
305+
}
306+
179307
async function releaseUserSeats(userId) {
308+
stopFileWatch(userId);
180309
const released = seats.releaseByUser(userId);
181310
await Promise.all(released.map((s) => closeSeatTab(s)));
182311
return released;
@@ -520,6 +649,13 @@ async function handleApi(req, res, url, sess) {
520649
presence.beat(id, sess.user);
521650
if (cdp && projectUrl) armSeatProjectJail(seat, projectUrl).catch(() => {});
522651
if (cdp) kickOnboard(id, sess.user, seat?.targetId);
652+
if (seat?.mode === "vnc") {
653+
try {
654+
await watchExclusiveFileChooser(id, sess.user);
655+
} catch {
656+
/* file pipe is best-effort — exclusive VNC still opens */
657+
}
658+
}
523659
return json(res, 200, { ok: true, id, mode: seat.mode, seat: publicSeat(seat), cap: seats.cap });
524660
}
525661
const paste = url.pathname.match(/^\/api\/desks\/([a-z0-9-]+)\/paste$/);
@@ -564,6 +700,28 @@ async function handleApi(req, res, url, sess) {
564700
return json(res, 502, { error: "无法粘贴" });
565701
}
566702
}
703+
const filesApi = url.pathname.match(/^\/api\/desks\/([a-z0-9-]+)\/files$/);
704+
if (filesApi && req.method === "POST") {
705+
const id = filesApi[1];
706+
if (!users.canOpen(sess.user, id) || !registry.has(id)) return json(res, 403, { error: "没有访问权限" });
707+
let body;
708+
try {
709+
body = await readFileUploadBody(req);
710+
} catch (e) {
711+
return json(res, e.status || 400, { error: e.message || FILE_UPLOAD_FAIL });
712+
}
713+
const out = await handleDeskFileUpload(id, sess.user, body);
714+
if (!out.ok) return json(res, out.status || 502, { error: out.error || FILE_UPLOAD_FAIL });
715+
return json(res, 200, { ok: true, kind: out.kind || (out.cancelled ? "cancel" : "file") });
716+
}
717+
const chooserApi = url.pathname.match(/^\/api\/desks\/([a-z0-9-]+)\/file-chooser$/);
718+
if (chooserApi && req.method === "GET") {
719+
const id = chooserApi[1];
720+
if (!users.canOpen(sess.user, id) || !registry.has(id)) return json(res, 403, { error: "没有访问权限" });
721+
const opened = await choosers.wait(id, sess.user.id, 20_000);
722+
if (!opened) return json(res, 200, { open: false });
723+
return json(res, 200, { open: true, mode: opened.mode || "selectSingle" });
724+
}
567725
const copy = url.pathname.match(/^\/api\/desks\/([a-z0-9-]+)\/copy$/);
568726
if (copy && req.method === "POST") {
569727
const id = copy[1];

gateway/web/app.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,13 @@ label.switch-row { cursor: pointer; }
811811
border-radius: 6px;
812812
}
813813
.toast span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
814+
.file-drop-shield {
815+
position: absolute;
816+
inset: 0;
817+
z-index: 4;
818+
display: none;
819+
}
820+
.file-drop-shield.on { display: block; }
814821
.frame { position: relative; background: #fff; }
815822
.frame iframe, .frame .seat-cast { width: 100%; height: 100%; border: 0; display: block; }
816823
.seat-cast { background: #fff; outline: none; cursor: default; touch-action: none; }

0 commit comments

Comments
 (0)