@@ -6,17 +6,19 @@ import httpProxy from "http-proxy";
66import { createSessionToken , readSession , createLoginLimiter , createSessionStore } from "../lib/auth.mjs" ;
77import { parseInstances } from "../lib/instances.mjs" ;
88import { 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" ;
1010import { createUserStore } from "../lib/users.mjs" ;
1111import { createPresence } from "../lib/presence.mjs" ;
1212import { createSocketHub , kickLiveSession } from "../lib/kick.mjs" ;
1313import { evaluateInDesk , waitForDesk , peekClipboard , isShareUrl , SHARE_CLICK , TAB_CLIP_READ , READ_PROJECT_URL , projectOnboardScript , listSeatProjectLinks , sleep } from "../lib/chrome.mjs" ;
1414import { applyDeskProxyLive , applyDeskProxiesLive } from "../lib/proxy.mjs" ;
1515import { 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" ;
1717import { createSeatJailRegistry , navigateSeatToUrl , pickNamedProjectHref , projectUrlFromOnboard , seatStartUrl } from "../lib/project-jail.mjs" ;
1818import { startSeatScreencast } from "../lib/screencast.mjs" ;
1919import { 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" ;
2022import { WebSocketServer } from "ws" ;
2123
2224const PORT = Number ( process . env . PORT || 8080 ) ;
@@ -51,8 +53,12 @@ const liveSockets = createSocketHub();
5153const seatWss = new WebSocketServer ( { noServer : true } ) ;
5254const loginLimiter = createLoginLimiter ( { maxFails : 10 , windowMs : 15 * 60 * 1000 } ) ;
5355const 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. */
5560const ONBOARD_YIELD_MS = 1500 ;
61+ const FILE_PIPE_WAIT_MS = 15_000 ;
5662
5763async 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" && / c h a t g p t \. c o m / 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+
179307async 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 ( / ^ \/ a p i \/ d e s k s \/ ( [ a - z 0 - 9 - ] + ) \/ p a s t e $ / ) ;
@@ -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 ( / ^ \/ a p i \/ d e s k s \/ ( [ a - z 0 - 9 - ] + ) \/ f i l e s $ / ) ;
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 ( / ^ \/ a p i \/ d e s k s \/ ( [ a - z 0 - 9 - ] + ) \/ f i l e - c h o o s e r $ / ) ;
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 ( / ^ \/ a p i \/ d e s k s \/ ( [ a - z 0 - 9 - ] + ) \/ c o p y $ / ) ;
568726 if ( copy && req . method === "POST" ) {
569727 const id = copy [ 1 ] ;
0 commit comments