@linkshell/host 0.4.44 → 0.4.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.
Files changed (69) hide show
  1. package/dist/host/src/drivers/acp/connection.js +2 -7
  2. package/dist/host/src/drivers/acp/connection.js.map +1 -1
  3. package/dist/host/src/drivers/acp/driver.js +2 -4
  4. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  5. package/dist/host/src/drivers/auth.js +1 -3
  6. package/dist/host/src/drivers/auth.js.map +1 -1
  7. package/dist/host/src/drivers/claude/driver.js +23 -11
  8. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  9. package/dist/host/src/drivers/codex/app-server.d.ts +6 -0
  10. package/dist/host/src/drivers/codex/app-server.js +44 -21
  11. package/dist/host/src/drivers/codex/app-server.js.map +1 -1
  12. package/dist/host/src/drivers/codex/driver.d.ts +1 -0
  13. package/dist/host/src/drivers/codex/driver.js +11 -7
  14. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  15. package/dist/host/src/drivers/codex/installation.d.ts +2 -0
  16. package/dist/host/src/drivers/codex/installation.js +29 -0
  17. package/dist/host/src/drivers/codex/installation.js.map +1 -0
  18. package/dist/host/src/fs.d.ts +13 -0
  19. package/dist/host/src/fs.js +29 -2
  20. package/dist/host/src/fs.js.map +1 -1
  21. package/dist/host/src/host.d.ts +1 -0
  22. package/dist/host/src/host.js +6 -4
  23. package/dist/host/src/host.js.map +1 -1
  24. package/dist/host/src/input.d.ts +29 -2
  25. package/dist/host/src/input.js +113 -5
  26. package/dist/host/src/input.js.map +1 -1
  27. package/dist/host/src/local-transport.d.ts +5 -0
  28. package/dist/host/src/local-transport.js +22 -0
  29. package/dist/host/src/local-transport.js.map +1 -0
  30. package/dist/host/src/ports.js +10 -1
  31. package/dist/host/src/ports.js.map +1 -1
  32. package/dist/host/src/process.d.ts +12 -0
  33. package/dist/host/src/process.js +59 -0
  34. package/dist/host/src/process.js.map +1 -0
  35. package/dist/host/src/rpc/client.js +2 -3
  36. package/dist/host/src/rpc/client.js.map +1 -1
  37. package/dist/host/src/rpc/server.d.ts +1 -0
  38. package/dist/host/src/rpc/server.js +12 -3
  39. package/dist/host/src/rpc/server.js.map +1 -1
  40. package/dist/host/src/screen-h264.d.ts +15 -0
  41. package/dist/host/src/screen-h264.js +88 -0
  42. package/dist/host/src/screen-h264.js.map +1 -0
  43. package/dist/host/src/screen-pacer.d.ts +7 -1
  44. package/dist/host/src/screen-pacer.js +11 -5
  45. package/dist/host/src/screen-pacer.js.map +1 -1
  46. package/dist/host/src/screen-session.d.ts +35 -0
  47. package/dist/host/src/screen-session.js +26 -0
  48. package/dist/host/src/screen-session.js.map +1 -0
  49. package/dist/host/src/screen-viewer.d.ts +1 -1
  50. package/dist/host/src/screen-viewer.js +373 -42
  51. package/dist/host/src/screen-viewer.js.map +1 -1
  52. package/dist/host/src/screen.d.ts +3 -1
  53. package/dist/host/src/screen.js +560 -165
  54. package/dist/host/src/screen.js.map +1 -1
  55. package/dist/host/src/terminals.js +6 -3
  56. package/dist/host/src/terminals.js.map +1 -1
  57. package/dist/host/src/windows-screen.d.ts +38 -0
  58. package/dist/host/src/windows-screen.js +80 -0
  59. package/dist/host/src/windows-screen.js.map +1 -0
  60. package/dist/host/src/windows-video.d.ts +78 -0
  61. package/dist/host/src/windows-video.js +368 -0
  62. package/dist/host/src/windows-video.js.map +1 -0
  63. package/dist/host/src/worktrees.js +2 -2
  64. package/dist/host/src/worktrees.js.map +1 -1
  65. package/dist/host/tsconfig.tsbuildinfo +1 -1
  66. package/dist/wire/src/rpc.d.ts +49 -0
  67. package/dist/wire/src/rpc.js +18 -0
  68. package/dist/wire/src/rpc.js.map +1 -1
  69. package/package.json +8 -6
@@ -9,6 +9,9 @@ import { closeInputApp, inputApp, InputControl, macAppSupported, NO_APP, videoSi
9
9
  import { LADDER, Pacer, RELAY_LEVEL, rung } from "./screen-pacer.js";
10
10
  import { viewerPage } from "./screen-viewer.js";
11
11
  import { screenMeasurementLog } from "./screen-metrics.js";
12
+ import { captureWindows, configureWindowsCapture, WindowsFrameSplitter, windowsDisplays, windowsHelper, windowsProfile } from "./windows-screen.js";
13
+ import { WindowsVideoSession, windowsMedia } from "./windows-video.js";
14
+ import { fallbackProfile, fallbackSize, SCREEN_RETAIN_MS, screenConfiguration } from "./screen-session.js";
12
15
  // Viewing the computer's screen from the phone. The host serves a small
13
16
  // viewer page on a loopback port; the phone opens it through the same
14
17
  // encrypted forwarder as port previews. A random token gates the port: other
@@ -30,9 +33,18 @@ async function hasFfmpeg() {
30
33
  }
31
34
  /** What the phone is told when the picture can't be had for want of the permission. */
32
35
  const notAllowed = (app) => `电脑还没有允许${app ? `「${app}」` : ""}录制屏幕。在电脑上运行 linkshell screen 按提示操作,或在「系统设置 › 隐私与安全性 › 录屏与系统录音」里打开${app ? `「${app}」` : "它"}的开关,然后重新进入这个页面。`;
36
+ async function captureReleased(capture) {
37
+ let timer;
38
+ try {
39
+ await Promise.race([capture.gone, new Promise((resolve) => { timer = setTimeout(resolve, 3000); timer.unref(); })]);
40
+ }
41
+ finally {
42
+ if (timer)
43
+ clearTimeout(timer);
44
+ }
45
+ }
33
46
  /** ffmpeg's capture of an X display (Linux), for one rung of the ladder (see `screen-pacer.ts`): 0 is the best picture. */
34
- export function captureArgs(level = 0) {
35
- const profile = rung(level);
47
+ export function captureArgs(level = 0, profile = fallbackProfile(level)) {
36
48
  const rate = (bits) => `${Math.round(bits / 1000)}k`;
37
49
  return [
38
50
  "-hide_banner",
@@ -207,6 +219,7 @@ export class ScreenShare {
207
219
  /** The viewer the app is sending the screen to as a video track. One at a time, like captures. */
208
220
  video;
209
221
  controls = new Set();
222
+ viewers = new Map();
210
223
  constructor(log,
211
224
  /** The servers both ends ask for their public address, to reach each other directly. */
212
225
  iceServers = () => []) {
@@ -224,18 +237,22 @@ export class ScreenShare {
224
237
  return { port, token: this.token, displays: displays.map(({ index, name }) => ({ index, name })) };
225
238
  }
226
239
  async listDisplays() {
240
+ if (process.platform === "win32")
241
+ return (await windowsDisplays()).map(({ screen, name, w, h }) => ({ index: screen, screen, name, w, h }));
227
242
  if (process.platform === "darwin") {
228
243
  const app = inputApp(this.log);
229
244
  if (!app)
230
245
  throw RpcError.app("not_supported", NO_APP);
231
246
  const displays = await app.displays();
232
- return displays.map(({ screen, name }) => ({ index: screen, screen, name: name || `屏幕 ${screen + 1}` }));
247
+ return displays.map(({ screen, name, w, h }) => ({ index: screen, screen, name: name || `屏幕 ${screen + 1}`, w, h }));
233
248
  }
234
249
  if (process.platform !== "linux")
235
250
  throw RpcError.app("not_supported", "这台电脑的系统暂不支持查看屏幕");
236
251
  if (!(await hasFfmpeg()))
237
252
  throw RpcError.app("not_supported", "查看屏幕需要电脑上装有 ffmpeg");
238
- return [{ index: 0, name: "屏幕", screen: 0 }];
253
+ // x11grab captures the X display's root window, which may span several monitors.
254
+ const dimensions = await run("xdpyinfo", [], { timeout: 5000 }).then(({ stdout }) => /dimensions:\s+(\d+)x(\d+)\s+pixels/.exec(stdout), () => null);
255
+ return [{ index: 0, name: "屏幕", screen: 0, ...(dimensions ? { w: Number(dimensions[1]), h: Number(dimensions[2]) } : {}) }];
239
256
  }
240
257
  listen() {
241
258
  if (this.server)
@@ -252,7 +269,7 @@ export class ScreenShare {
252
269
  response.writeHead(302, { location: `${url.pathname}${url.search}`, "cache-control": "no-store" }).end();
253
270
  return;
254
271
  }
255
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(viewerPage());
272
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(viewerPage(process.platform));
256
273
  });
257
274
  const sockets = new WebSocketServer({ noServer: true });
258
275
  server.on("upgrade", (request, socket, head) => {
@@ -289,6 +306,16 @@ export class ScreenShare {
289
306
  * (`ffmpeg`: whether what captures is there — ffmpeg itself on Linux.)
290
307
  */
291
308
  async access(ask) {
309
+ if (process.platform === "win32") {
310
+ const found = Boolean(windowsHelper());
311
+ try {
312
+ const displays = await windowsDisplays();
313
+ return { supported: true, ffmpeg: true, recording: displays.length > 0, control: true, app: "LinkShell" };
314
+ }
315
+ catch (error) {
316
+ return { supported: true, ffmpeg: found, recording: false, control: false, problem: error.message };
317
+ }
318
+ }
292
319
  const supported = macAppSupported() || process.platform === "linux";
293
320
  if (process.platform !== "darwin")
294
321
  return { supported, ffmpeg: supported && (await hasFfmpeg()), recording: null, control: null, problem: "controlling the screen needs macOS" };
@@ -310,7 +337,7 @@ export class ScreenShare {
310
337
  }
311
338
  }
312
339
  /** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
313
- async capture(screen, level, on) {
340
+ async capture(screen, level, on, profile = fallbackProfile(level), wanted = () => true) {
314
341
  let ended = false;
315
342
  let leave = () => { };
316
343
  const gone = new Promise((resolve) => (leave = resolve));
@@ -321,6 +348,8 @@ export class ScreenShare {
321
348
  return undefined;
322
349
  }
323
350
  const status = await app.access();
351
+ if (!wanted())
352
+ return undefined;
324
353
  if (!status.recording) {
325
354
  // Someone may be at the computer: the app's window for its permissions comes up there.
326
355
  void app.ask().catch(() => { });
@@ -328,9 +357,9 @@ export class ScreenShare {
328
357
  return undefined;
329
358
  }
330
359
  let size;
331
- const stream = await app.stream(screen, rung(level), {
360
+ const stream = await app.stream(screen, profile, {
332
361
  frame: (unit, key, generation) => {
333
- if (ended)
362
+ if (ended || !wanted())
334
363
  return;
335
364
  if (size !== undefined && generation !== size)
336
365
  on.resized?.();
@@ -339,37 +368,54 @@ export class ScreenShare {
339
368
  },
340
369
  exit: (error) => {
341
370
  leave();
342
- if (ended)
371
+ if (ended || !wanted())
343
372
  return;
344
373
  if (error)
345
374
  this.log(`[screen] the app's capture ended: ${error}`);
346
375
  on.exit(!error ? undefined : /permission|not allowed|not authorized|denied|declined/i.test(error) ? notAllowed(status.app) : "屏幕捕获失败");
347
376
  },
348
377
  });
378
+ const ready = stream.ready.then(({ width, height, fps }) => ({ width, height, maxFps: fps }));
379
+ void ready.catch(() => { });
349
380
  return {
350
381
  on,
351
382
  gone,
383
+ ready,
352
384
  end: () => {
353
385
  ended = true;
354
386
  stream.end();
355
387
  leave();
356
388
  },
357
- change: (next) => stream.set(rung(next)),
389
+ change: (next, requested) => stream.set(requested ?? rung(next)),
358
390
  key: () => stream.key(),
359
391
  };
360
392
  }
361
- const capture = spawn("ffmpeg", captureArgs(level), { stdio: ["ignore", "pipe", "pipe"] });
393
+ if (!wanted())
394
+ return undefined;
395
+ const windows = process.platform === "win32";
396
+ const capture = windows ? captureWindows(screen, { ...profile, quality: windowsProfile(level).quality }) : spawn("ffmpeg", captureArgs(level, profile), { stdio: ["ignore", "pipe", "pipe"] });
362
397
  this.captures.add(capture);
363
398
  const splitter = new AccessUnitSplitter();
399
+ const jpeg = new WindowsFrameSplitter();
364
400
  let errors = "";
365
401
  capture.stdout.on("data", (chunk) => {
366
- if (!ended)
367
- splitter.push(chunk, on.frame);
402
+ if (ended || !wanted())
403
+ return;
404
+ try {
405
+ if (windows)
406
+ jpeg.push(chunk, frame => on.frame(frame, true));
407
+ else
408
+ splitter.push(chunk, on.frame);
409
+ }
410
+ catch (error) {
411
+ errors = error.message;
412
+ endCapture(capture, true);
413
+ }
368
414
  });
369
415
  capture.stderr.on("data", (chunk) => (errors = (errors + chunk.toString()).slice(-2000)));
370
416
  const finished = (code, said) => {
371
417
  leave();
372
- if (ended)
418
+ if (ended || !wanted())
373
419
  return;
374
420
  if (code)
375
421
  this.log(`[screen] capture exited ${code}: ${said.trim()}`);
@@ -387,11 +433,17 @@ export class ScreenShare {
387
433
  ended = true;
388
434
  endCapture(capture);
389
435
  },
436
+ ...(windows ? {
437
+ change: (next, requested) => configureWindowsCapture(capture, { t: "profile", ...(requested ?? profile), quality: windowsProfile(next).quality }),
438
+ key: () => configureWindowsCapture(capture, { t: "keyframe" }),
439
+ } : {}),
390
440
  };
391
441
  }
392
442
  /** Takes the screen for a new capture: whatever holds it is ended, and has left, before the new one starts. */
393
- take(screen, level, on, wanted) {
443
+ take(screen, level, on, wanted, profile = fallbackProfile(level)) {
394
444
  const mine = this.turn.then(async () => {
445
+ if (!wanted())
446
+ return undefined;
395
447
  this.video?.taken();
396
448
  const previous = this.holder;
397
449
  if (previous) {
@@ -400,11 +452,15 @@ export class ScreenShare {
400
452
  // Another device's picture ends here; a viewer changing its own just carries on.
401
453
  if (previous.on !== on)
402
454
  previous.on.exit("屏幕画面被另一台设备接手了");
403
- await Promise.race([previous.gone, new Promise((resolve) => setTimeout(resolve, 3000))]);
455
+ await captureReleased(previous);
404
456
  }
405
457
  if (!wanted())
406
458
  return undefined;
407
- const running = await this.capture(screen, level, on);
459
+ const running = await this.capture(screen, level, on, profile, wanted);
460
+ if (!wanted()) {
461
+ running?.end();
462
+ return undefined;
463
+ }
408
464
  this.holder = running;
409
465
  return running;
410
466
  });
@@ -416,6 +472,56 @@ export class ScreenShare {
416
472
  * app without the media engine): the picture then goes down the socket.
417
473
  */
418
474
  async offerVideo(screen, maxWidth, maxFps, diagnostics, codec, viewer) {
475
+ if (process.platform === "win32" && process.env.LINKSHELL_SCREEN_VIDEO !== "off" && windowsMedia()) {
476
+ // The native guard checks the selected desktop and the local opt-out throughout capture.
477
+ await windowsDisplays();
478
+ if (viewer.closed())
479
+ return "refused";
480
+ this.video?.taken();
481
+ const holder = this.holder;
482
+ if (holder) {
483
+ this.holder = undefined;
484
+ holder.end();
485
+ holder.on.exit("屏幕画面被另一台设备接手了");
486
+ await captureReleased(holder);
487
+ }
488
+ if (viewer.closed())
489
+ return "refused";
490
+ const iceServers = this.iceServers().map(url => ({ urls: [url] }));
491
+ viewer.tell({ rtc: { t: "config", iceServers } });
492
+ const session = new WindowsVideoSession({ screen, iceServers, maxWidth, maxFps }, {
493
+ tell: message => viewer.tell({ rtc: message }), input: viewer.input, lost: viewer.lost, log: this.log,
494
+ });
495
+ const mine = { id: session.id, taken: () => {
496
+ if (this.video === mine)
497
+ this.video = undefined;
498
+ session.end();
499
+ viewer.refuse("屏幕画面被另一台设备接手了");
500
+ } };
501
+ this.video = mine;
502
+ const cancelled = setInterval(() => { if (viewer.closed())
503
+ session.end(); }, 100);
504
+ try {
505
+ await session.offer();
506
+ if (viewer.closed() || this.video !== mine) {
507
+ session.end();
508
+ if (this.video === mine)
509
+ this.video = undefined;
510
+ return "refused";
511
+ }
512
+ return session;
513
+ }
514
+ catch (error) {
515
+ session.end();
516
+ if (this.video === mine)
517
+ this.video = undefined;
518
+ this.log(`[screen] Windows video could not start: ${String(error)}`);
519
+ return undefined;
520
+ }
521
+ finally {
522
+ clearInterval(cancelled);
523
+ }
524
+ }
419
525
  // LINKSHELL_SCREEN_VIDEO=off: every viewer gets the picture down the socket (to compare the two, or should the track ever misbehave).
420
526
  const app = process.platform === "darwin" && process.env.LINKSHELL_SCREEN_VIDEO !== "off" ? inputApp(this.log) : undefined;
421
527
  if (!app)
@@ -435,10 +541,14 @@ export class ScreenShare {
435
541
  this.video?.taken();
436
542
  const holder = this.holder;
437
543
  if (holder) {
438
- this.holder = undefined;
439
544
  holder.end();
440
545
  holder.on.exit("屏幕画面被另一台设备接手了");
546
+ await captureReleased(holder);
547
+ if (this.holder === holder)
548
+ this.holder = undefined;
441
549
  }
550
+ if (viewer.closed())
551
+ return "refused";
442
552
  const id = `video-${randomBytes(6).toString("hex")}`;
443
553
  const mine = {
444
554
  id,
@@ -480,44 +590,153 @@ export class ScreenShare {
480
590
  viewer.tell({ rtc: { ...rest, t: kind.slice(4) } });
481
591
  }
482
592
  });
483
- return { app, id };
593
+ if (viewer.closed() || this.video !== mine) {
594
+ app.endVideo(id);
595
+ if (this.video === mine)
596
+ this.video = undefined;
597
+ return "refused";
598
+ }
599
+ return { id, reconfigure: status.videoReconfigure === true, configure: configuration => app.configureVideo(id, configuration), signal: message => app.signal(id, message), end: () => app.endVideo(id) };
484
600
  }
485
601
  stream(ws, display, relayed, wantsVideo, width, maxFps, diagnostics = false, codec) {
486
- const shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
487
- const tell = (message) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(message));
488
- // The viewer's hands: started when it first asks to control, for the display it is watching.
489
- const control = new InputControl({
490
- state: (state) => tell({ control: state }),
491
- cursor: (x, y) => tell({ cursor: { x, y } }),
492
- posted: (event) => this.log(`[screen] dry run: ${JSON.stringify(event)}`),
493
- }, this.log);
494
- this.controls.add(control);
495
- // The picture: as good as the path carries without falling behind (see `screen-pacer.ts`).
496
- const best = relayed ? RELAY_LEVEL : 0;
497
- const pacer = new Pacer(Date.now());
498
- let level = best;
602
+ // Paused viewers still own a session. A new authenticated viewer takes that ownership too.
603
+ for (const [previous, release] of this.viewers) {
604
+ if (previous.readyState === previous.OPEN)
605
+ previous.send(JSON.stringify({ error: "屏幕画面被另一台设备接手了" }));
606
+ release();
607
+ previous.close();
608
+ }
609
+ let shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
610
+ let desired = { display: shown?.index ?? 0, width: width ?? 1920, maxFps: maxFps === 30 || maxFps === 60 || maxFps === 120 ? maxFps : undefined, paused: false };
499
611
  let closed = false;
612
+ const live = () => !closed && ws.readyState === ws.OPEN;
613
+ const tell = (message) => { if (live())
614
+ ws.send(JSON.stringify(message)); };
615
+ let control;
616
+ let controlRequested = false;
617
+ let inputEnabled = false;
618
+ let generationEnabled = false;
619
+ let inputGeneration = 0;
620
+ let appliedGeneration;
621
+ let revision = 0;
622
+ let lastRequestId = -1;
623
+ let desiredRequestId;
624
+ let helperRequestId = 0;
625
+ let pending;
626
+ let configuring = false;
627
+ let initialized = false;
628
+ let canConfigure = false;
629
+ let pausedExpiry;
630
+ let running;
631
+ let captureGeneration = 0;
632
+ let video;
633
+ let signals = Promise.resolve();
634
+ const windows = process.platform === "win32";
635
+ const best = relayed ? windows ? 1 : RELAY_LEVEL : 0;
636
+ const pacer = new Pacer(Date.now(), windows ? { maxInFlight: 2, behindMs: 120, caughtUpMs: 50, probeMs: 1500 } : {});
637
+ const profileFor = (next, configuration) => ({
638
+ ...fallbackProfile(next, configuration),
639
+ ...(windows ? windowsProfile(next, configuration.width, configuration.maxFps) : {}),
640
+ });
641
+ let level = best;
500
642
  let changing = false;
501
643
  let lighter = false;
502
644
  let askedForKey = 0;
503
- // Nobody is looking at the viewer (its page is hidden): nothing is sent, and its slowness to answer means nothing.
504
- let unwatched = false;
505
- // Sending again starts at a keyframe.
506
- let wantsKey = false;
507
- let running;
508
- // Dropping, with only a keyframe missing to carry on: a capture that makes one when asked is asked.
645
+ let wantsKey = true;
646
+ const stopControl = () => {
647
+ inputEnabled = false;
648
+ if (!control)
649
+ return;
650
+ control.stop();
651
+ this.controls.delete(control);
652
+ control = undefined;
653
+ };
654
+ const startControl = async (preparing = false) => {
655
+ if (!live() || (!inputEnabled && !preparing) || desired.paused || !controlRequested || control)
656
+ return;
657
+ const at = revision;
658
+ const created = new InputControl({
659
+ // Readiness may arrive while the applied target is still being acknowledged; it is
660
+ // safe to report permissions then, while the independent input gate stays closed.
661
+ state: (state) => { if (control === created && revision === at)
662
+ tell({ control: state }); },
663
+ cursor: (x, y) => { if (control === created && inputEnabled && !video?.cursor?.(x, y))
664
+ tell({ cursor: { x, y } }); },
665
+ posted: (event) => this.log(`[screen] dry run: ${JSON.stringify(event)}`),
666
+ }, this.log);
667
+ control = created;
668
+ this.controls.add(created);
669
+ await created.start(shown?.screen ?? 0);
670
+ if (!live() || control !== created || revision !== at || (!inputEnabled && !preparing)) {
671
+ created.stop();
672
+ this.controls.delete(created);
673
+ }
674
+ };
675
+ const stopRunning = () => {
676
+ captureGeneration += 1;
677
+ const stopped = running;
678
+ running = undefined;
679
+ stopped?.end();
680
+ // ffmpeg can hold the recorder until SIGKILL; the next capture must wait for it.
681
+ if (stopped && this.holder === stopped)
682
+ void stopped.gone.then(() => { if (this.holder === stopped)
683
+ this.holder = undefined; });
684
+ };
685
+ const endVideo = () => {
686
+ if (!video)
687
+ return;
688
+ const ended = video;
689
+ video = undefined;
690
+ ended.end();
691
+ if (this.video?.id === ended.id)
692
+ this.video = undefined;
693
+ };
694
+ const expirePaused = () => {
695
+ if (pausedExpiry)
696
+ return;
697
+ pausedExpiry = setTimeout(() => {
698
+ pausedExpiry = undefined;
699
+ if (live()) {
700
+ dispose();
701
+ ws.close(1000, "screen retention expired");
702
+ }
703
+ }, SCREEN_RETAIN_MS);
704
+ pausedExpiry.unref();
705
+ };
706
+ const resumed = () => {
707
+ if (pausedExpiry)
708
+ clearTimeout(pausedExpiry);
709
+ pausedExpiry = undefined;
710
+ };
711
+ const capabilities = () => tell({ session: { t: "capabilities", version: 1, configure: canConfigure, pause: video ? !!(video.reconfigure || video.pause) : true, retainMs: SCREEN_RETAIN_MS } });
712
+ const failure = (error) => {
713
+ if (!live())
714
+ return;
715
+ this.log(`[screen] the capture did not start: ${String(error)}`);
716
+ tell({ error: "屏幕捕获失败" });
717
+ dispose();
718
+ ws.close();
719
+ };
509
720
  const askForKey = () => {
721
+ if (!live() || desired.paused || configuring)
722
+ return;
510
723
  const now = Date.now();
511
724
  if (!running?.key || now - askedForKey < 500 || !pacer.needsKey(now))
512
725
  return;
513
726
  askedForKey = now;
514
727
  running.key();
515
728
  };
516
- // A still screen sends no frame to be reminded by.
517
- const reminder = setInterval(askForKey, 500);
729
+ let reminder;
730
+ const stopReminder = () => { if (reminder)
731
+ clearInterval(reminder); reminder = undefined; };
732
+ const startReminder = () => { if (!reminder)
733
+ reminder = setInterval(askForKey, 500); };
734
+ startReminder();
518
735
  const events = {
519
736
  frame: (unit, key) => {
520
- if (ws.readyState !== ws.OPEN || unwatched)
737
+ if (!live() || desired.paused)
738
+ return;
739
+ if (windows && ws.bufferedAmount > 256 * 1024)
521
740
  return;
522
741
  if (wantsKey && !key)
523
742
  return;
@@ -526,110 +745,239 @@ export class ScreenShare {
526
745
  const seq = pacer.next(key, now);
527
746
  if (seq !== undefined) {
528
747
  const head = Buffer.allocUnsafe(5);
529
- head[0] = key ? 1 : 0;
748
+ head[0] = windows ? 2 : key ? 1 : 0;
530
749
  head.writeUInt32BE(seq >>> 0, 1);
531
750
  ws.send(Buffer.concat([head, unit]));
532
751
  }
533
752
  else
534
753
  askForKey();
535
- const advice = changing ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
754
+ const advice = changing || configuring ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
536
755
  if (advice)
537
- void change(level + (advice === "down" ? 1 : -1));
756
+ void changeLevel(level + (advice === "down" ? 1 : -1));
538
757
  },
539
- // The page starts its decoder afresh for the new size; what it had of the old one came before this, in order.
540
758
  resized: () => tell({ restart: true, level, lighter }),
541
- exit: (message) => {
542
- if (message)
543
- tell({ error: message });
544
- ws.close();
545
- },
759
+ exit: (message) => { if (message)
760
+ tell({ error: message }); dispose(); ws.close(); },
546
761
  };
547
- const begin = async () => {
548
- const started = await this.take(shown?.screen ?? 0, level, events, () => !closed);
549
- if (closed)
762
+ const begin = async (at, target, configuration) => {
763
+ const captureAt = ++captureGeneration;
764
+ const wanted = () => live() && revision === at && captureGeneration === captureAt && !desired.paused && !video;
765
+ const scoped = {
766
+ frame: (unit, key) => { if (wanted())
767
+ events.frame(unit, key); },
768
+ resized: () => { if (wanted())
769
+ events.resized?.(); },
770
+ exit: (message) => { if (wanted())
771
+ events.exit(message); },
772
+ };
773
+ const started = await this.take(target?.screen ?? 0, level, scoped, wanted, profileFor(level, configuration));
774
+ if (!wanted()) {
775
+ if (this.holder === started)
776
+ this.holder = undefined;
550
777
  started?.end();
551
- else
778
+ }
779
+ else {
552
780
  running = started;
781
+ if (started?.ready) {
782
+ let timer;
783
+ try {
784
+ const size = await Promise.race([started.ready, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("屏幕捕获启动超时")), 15_000); timer.unref(); })]);
785
+ if (wanted())
786
+ return size;
787
+ }
788
+ catch (error) {
789
+ if (wanted())
790
+ throw error;
791
+ }
792
+ finally {
793
+ if (timer)
794
+ clearTimeout(timer);
795
+ }
796
+ }
797
+ }
798
+ return undefined;
553
799
  };
554
- const failed = (error) => {
555
- this.log(`[screen] the capture did not start: ${error.message}`);
556
- tell({ error: "屏幕捕获失败" });
557
- ws.close();
558
- };
559
- const change = async (next) => {
800
+ const changeLevel = async (next) => {
801
+ if (!live() || desired.paused || configuring || changing)
802
+ return;
560
803
  changing = true;
561
804
  lighter = next > level;
562
- const to = rung(next);
563
- this.log(`[screen] picture ${lighter ? "lighter" : "better"}: ${to.width} wide, ${to.fps} a second, ${Math.round(to.bitrate / 1000)} kbit/s`);
805
+ level = next;
564
806
  if (running?.change) {
565
- // The capture carries on: the new level takes over at a keyframe, with nothing lost in between.
566
- running.change(next);
567
- level = next;
807
+ running.change(next, profileFor(next, desired));
568
808
  pacer.changed(Date.now());
569
- changing = false;
570
- return;
571
809
  }
572
- // Nothing more of the old stream goes out; the page starts its decoder afresh for the new one.
573
- running?.end();
574
- tell({ restart: true, level: next, lighter });
575
- level = next;
576
- await begin().catch(failed);
577
- pacer.restarted(Date.now());
810
+ else {
811
+ stopRunning();
812
+ tell({ restart: true, level, lighter });
813
+ await begin(revision, shown, desired).catch(failure);
814
+ pacer.restarted(Date.now());
815
+ }
578
816
  changing = false;
579
817
  };
580
- // The picture as a video track; down this socket when that can't be had, or didn't get across.
581
- let video;
582
- /** The viewer's answer and candidates, kept in the order they arrived (`resolvedCandidate`). */
583
- let signals = Promise.resolve();
584
- const endVideo = () => {
585
- if (!video)
818
+ const current = (change) => live() && revision === change.revision;
819
+ const drain = async () => {
820
+ if (!initialized || configuring || !live())
586
821
  return;
587
- video.app.endVideo(video.id);
588
- if (this.video?.id === video.id)
589
- this.video = undefined;
590
- video = undefined;
822
+ configuring = true;
823
+ try {
824
+ while (pending && live()) {
825
+ const change = pending;
826
+ pending = undefined;
827
+ const configuration = change.configuration;
828
+ try {
829
+ if (!canConfigure && change.requestId !== undefined)
830
+ throw new Error("电脑上的屏幕组件不支持保留连接,请重新连接");
831
+ // A monitor may have been unplugged since screen.start listed it.
832
+ const displays = await this.listDisplays();
833
+ if (!current(change))
834
+ continue;
835
+ this.displays = displays;
836
+ const target = displays.find((entry) => entry.index === configuration.display);
837
+ if (!target)
838
+ throw new Error("所选屏幕已不可用,请选择其他屏幕");
839
+ let applied;
840
+ if (video) {
841
+ if (!video.reconfigure || !video.configure) {
842
+ // Windows can pause its existing track, but target changes still reconnect.
843
+ if (video.pause && change.requestId === undefined) {
844
+ video.pause(configuration.paused);
845
+ applied = { screen: target.screen, width: target.w ?? 0, height: target.h ?? 0, maxFps: configuration.maxFps ?? 60, paused: configuration.paused };
846
+ }
847
+ else {
848
+ if (configuration.paused) {
849
+ dispose();
850
+ ws.close();
851
+ return;
852
+ }
853
+ throw new Error("电脑上的屏幕组件不支持保留连接,请重新连接");
854
+ }
855
+ }
856
+ else
857
+ applied = await video.configure({
858
+ requestId: ++helperRequestId, screen: target.screen, maxWidth: configuration.width,
859
+ ...(configuration.maxFps === undefined ? {} : { maxFps: configuration.maxFps }), paused: configuration.paused,
860
+ ...(change.generation === undefined ? {} : { inputGeneration: change.generation }),
861
+ });
862
+ if (applied.screen !== target.screen || applied.paused !== configuration.paused || (change.generation !== undefined && applied.generation !== change.generation)) {
863
+ throw new Error("电脑返回的屏幕设置无效,请重试");
864
+ }
865
+ }
866
+ else {
867
+ const profile = profileFor(level, configuration);
868
+ let size = !target.w || !target.h ? { width: 0, height: 0 } : fallbackSize(target, profile);
869
+ let fps = profile.fps;
870
+ if ((!size.width || !size.height) && change.requestId !== undefined)
871
+ throw new Error("无法读取当前屏幕尺寸,请重新连接");
872
+ stopRunning();
873
+ if (!configuration.paused) {
874
+ wantsKey = true;
875
+ pacer.restarted(Date.now());
876
+ tell({ restart: true, level, lighter: false });
877
+ const started = await begin(change.revision, target, configuration);
878
+ if (started) {
879
+ size = started;
880
+ fps = started.maxFps;
881
+ }
882
+ }
883
+ applied = { screen: target.screen, ...size, maxFps: fps, paused: configuration.paused, generation: change.generation };
884
+ }
885
+ if (!current(change))
886
+ continue;
887
+ shown = target;
888
+ appliedGeneration = change.generation;
889
+ if (!configuration.paused)
890
+ await startControl(true);
891
+ if (!current(change))
892
+ continue;
893
+ inputEnabled = !configuration.paused;
894
+ if (!configuration.paused) {
895
+ resumed();
896
+ startReminder();
897
+ }
898
+ if (change.requestId !== undefined)
899
+ tell({ session: {
900
+ t: "configured", requestId: change.requestId, display: target.index, width: applied.width, height: applied.height,
901
+ maxFps: applied.maxFps, paused: applied.paused,
902
+ ...(applied.generation === undefined ? {} : { generation: applied.generation }),
903
+ } });
904
+ }
905
+ catch (error) {
906
+ if (!current(change))
907
+ continue;
908
+ stopControl();
909
+ if (!video)
910
+ stopRunning();
911
+ this.log(`[screen] configuration failed: ${String(error)}`);
912
+ if (change.requestId !== undefined)
913
+ tell({ session: { t: "error", requestId: change.requestId, message: "屏幕设置未能应用,请重试" } });
914
+ else if (configuration.paused) {
915
+ dispose();
916
+ ws.close();
917
+ }
918
+ }
919
+ }
920
+ }
921
+ finally {
922
+ configuring = false;
923
+ }
591
924
  };
592
- const fallBack = () => {
593
- if (!video)
594
- return;
925
+ const configure = (configuration, requestId) => {
926
+ desired = configuration;
927
+ if (requestId !== undefined) {
928
+ generationEnabled = true;
929
+ desiredRequestId = requestId;
930
+ }
931
+ const at = ++revision;
932
+ pending = { configuration, revision: at, requestId, ...(generationEnabled ? { generation: ++inputGeneration } : {}) };
933
+ stopControl();
934
+ if (configuration.paused) {
935
+ stopReminder();
936
+ stopRunning();
937
+ expirePaused();
938
+ }
939
+ void drain();
940
+ };
941
+ const startFallback = () => {
595
942
  endVideo();
596
- if (closed)
943
+ if (!live())
597
944
  return;
598
945
  tell({ rtc: { t: "off" } });
599
- void begin().catch(failed);
946
+ canConfigure = !!shown?.w && !!shown?.h;
947
+ initialized = true;
948
+ capabilities();
949
+ if (pending || desiredRequestId !== undefined || desired.paused) {
950
+ configure(desired, desiredRequestId);
951
+ }
952
+ else {
953
+ const at = revision;
954
+ void begin(at, shown, desired).then(() => {
955
+ if (live() && revision === at && !desired.paused) {
956
+ inputEnabled = true;
957
+ void startControl();
958
+ }
959
+ }).catch(failure);
960
+ }
600
961
  };
601
- if (wantsVideo) {
602
- this.offerVideo(shown?.screen ?? 0, width, maxFps, diagnostics, codec, {
603
- closed: () => closed,
604
- tell,
605
- refuse: (message) => {
606
- video = undefined;
607
- tell({ error: message });
608
- ws.close();
609
- },
610
- lost: fallBack,
611
- })
612
- .then((offered) => {
613
- if (offered === "refused")
614
- return;
615
- if (closed) {
616
- if (offered)
617
- offered.app.endVideo(offered.id);
618
- return;
619
- }
620
- if (offered)
621
- video = offered;
622
- else {
623
- tell({ rtc: { t: "off" } });
624
- return begin();
625
- }
626
- })
627
- .catch(failed);
628
- }
629
- else
630
- void begin().catch(failed);
962
+ const fallBack = () => { if (video)
963
+ startFallback(); };
964
+ const dispose = () => {
965
+ if (closed)
966
+ return;
967
+ closed = true;
968
+ revision += 1;
969
+ pending = undefined;
970
+ stopReminder();
971
+ resumed();
972
+ stopRunning();
973
+ endVideo();
974
+ stopControl();
975
+ this.viewers.delete(ws);
976
+ };
977
+ this.viewers.set(ws, dispose);
978
+ ws.on("close", dispose);
631
979
  ws.on("message", (data, binary) => {
632
- if (binary)
980
+ if (binary || !live())
633
981
  return;
634
982
  let message;
635
983
  try {
@@ -639,77 +987,124 @@ export class ScreenShare {
639
987
  return;
640
988
  }
641
989
  const kind = message?.t;
642
- if (kind === "ack")
990
+ if (kind === "screen.configure") {
991
+ const parsed = screenConfiguration.safeParse(message);
992
+ if (!parsed.success || !this.displays.some((entry) => entry.index === parsed.data.display)) {
993
+ const requestId = message.requestId;
994
+ tell({ session: { t: "error", ...(typeof requestId === "number" && Number.isSafeInteger(requestId) && requestId >= 0 ? { requestId } : {}), message: "屏幕设置无效,请检查屏幕、分辨率和帧率" } });
995
+ return;
996
+ }
997
+ if (parsed.data.requestId <= lastRequestId)
998
+ return;
999
+ if (initialized && !canConfigure) {
1000
+ tell({ session: { t: "error", requestId: parsed.data.requestId, message: "电脑上的屏幕组件不支持保留连接,请重新连接" } });
1001
+ return;
1002
+ }
1003
+ lastRequestId = parsed.data.requestId;
1004
+ const { t: _type, requestId, ...configuration } = parsed.data;
1005
+ configure(configuration, requestId);
1006
+ }
1007
+ else if (kind === "hidden" || kind === "shown") {
1008
+ const paused = kind === "hidden";
1009
+ if (desired.paused !== paused)
1010
+ configure({ ...desired, paused });
1011
+ }
1012
+ else if (kind === "ack")
643
1013
  pacer.ack(Number(message.n), Date.now());
644
1014
  else if (kind === "rtc.answer" || kind === "rtc.ice") {
645
1015
  const signal = videoSignal.safeParse(message);
646
1016
  if (!signal.success)
647
1017
  return;
648
- // Resolving a name takes a moment, and the answer must still reach the app before the
649
- // candidates that followed it: each signal waits for the one before.
650
- signals = signals
651
- .then(async () => {
652
- const data = signal.data;
653
- if (!video)
1018
+ signals = signals.then(async () => {
1019
+ const target = video;
1020
+ if (!target || !live())
654
1021
  return;
655
- video.app.signal(video.id, data.t === "rtc.ice" ? { ...data, candidate: await resolvedCandidate(data.candidate) } : data);
656
- })
657
- // One signal that couldn't be passed on must not take the ones behind it with it.
658
- .catch(() => { });
1022
+ const data = signal.data;
1023
+ const resolved = data.t === "rtc.ice" ? { ...data, candidate: await resolvedCandidate(data.candidate) } : data;
1024
+ if (live() && video === target)
1025
+ target.signal(resolved);
1026
+ }).catch(() => { });
659
1027
  }
660
- // The viewer's decoder lost its place (it skipped ahead, or failed): it can only carry on from a keyframe.
661
- // (Named apart from the viewer's own events: whatever isn't one of these kinds is taken for a pointer or key event.)
662
1028
  else if (kind === "keyframe") {
663
1029
  const now = Date.now();
664
- if (running?.key && now - askedForKey >= 500) {
1030
+ if (!desired.paused && !configuring && running?.key && now - askedForKey >= 500) {
665
1031
  askedForKey = now;
666
1032
  running.key();
667
1033
  }
668
1034
  }
669
- else if (kind === "hidden")
670
- unwatched = true;
671
- else if (kind === "shown") {
672
- if (!unwatched)
673
- return;
674
- unwatched = false;
675
- wantsKey = true;
676
- // What was on its way when the page went out of sight is not waited for.
677
- pacer.restarted(Date.now());
678
- running?.key?.();
679
- }
680
- // Why a viewer gave its track up: what ICE gathered and tried, for this log only.
681
1035
  else if (kind === "rtc.diagnosis") {
682
1036
  const { reason, ice } = message;
683
1037
  this.log(`[screen] the video track was given up (${String(reason ?? "no reason").slice(0, 120)}): ${String(ice || "no ICE statistics").slice(0, 300)}`);
684
1038
  }
685
- // No direct path between the two, or none in time: the viewer asks for the picture here instead.
686
- else if (kind === "rtc.failed") {
687
- this.log(`[screen] no video track for this viewer (${String(message.reason ?? "no reason given").slice(0, 200)}): the picture goes down the socket`);
1039
+ else if (kind === "rtc.failed")
688
1040
  fallBack();
689
- }
690
- // A viewer measuring (`?measure=1`): its clock against this computer's (it keeps the answer that came back
691
- // quickest), then what it sees, once a second.
692
1041
  else if (kind === "ping")
693
1042
  tell({ pong: { n: message.n, at: message.at, now: Date.now() } });
694
1043
  else if (kind === "measure")
695
1044
  this.log(`[screen] measured: ${screenMeasurementLog(message)}`);
696
- else if (kind === "control")
697
- void control.start(shown?.screen ?? 0);
698
- else
699
- control.send(message);
700
- });
701
- ws.on("close", () => {
702
- closed = true;
703
- clearInterval(reminder);
704
- if (running && this.holder === running)
705
- this.holder = undefined;
706
- running?.end();
707
- endVideo();
708
- control.stop();
709
- this.controls.delete(control);
1045
+ else if (kind === "control" && !desired.paused) {
1046
+ // Requesting control only records intent. Input still needs the applied generation,
1047
+ // and startup/configuration must finish before the helper can be joined.
1048
+ controlRequested = true;
1049
+ void startControl();
1050
+ }
1051
+ else if (!desired.paused && inputEnabled && (!generationEnabled || message?.screenGeneration === appliedGeneration)) {
1052
+ control?.send(message);
1053
+ }
710
1054
  });
1055
+ if (wantsVideo) {
1056
+ let lostWhileOpening = false;
1057
+ void this.offerVideo(shown?.screen ?? 0, width, maxFps, diagnostics, codec, {
1058
+ closed: () => !live(), tell,
1059
+ refuse: (message) => { tell({ error: message }); dispose(); ws.close(); },
1060
+ lost: () => { if (video)
1061
+ fallBack();
1062
+ else
1063
+ lostWhileOpening = true; },
1064
+ input: message => {
1065
+ if (!live() || desired.paused || !inputEnabled)
1066
+ return;
1067
+ controlRequested = true;
1068
+ const at = revision;
1069
+ void startControl().then(() => { if (live() && revision === at && inputEnabled && !desired.paused)
1070
+ control?.send(message); });
1071
+ },
1072
+ }).then((offered) => {
1073
+ if (offered === "refused")
1074
+ return;
1075
+ if (!live()) {
1076
+ offered?.end();
1077
+ return;
1078
+ }
1079
+ if (!offered) {
1080
+ startFallback();
1081
+ return;
1082
+ }
1083
+ video = offered;
1084
+ if (lostWhileOpening) {
1085
+ startFallback();
1086
+ return;
1087
+ }
1088
+ initialized = true;
1089
+ canConfigure = offered.reconfigure === true;
1090
+ capabilities();
1091
+ if (pending)
1092
+ void drain();
1093
+ else {
1094
+ inputEnabled = true;
1095
+ void startControl();
1096
+ }
1097
+ }).catch(failure);
1098
+ }
1099
+ else
1100
+ startFallback();
711
1101
  }
712
1102
  stop() {
1103
+ for (const [viewer, release] of this.viewers) {
1104
+ release();
1105
+ viewer.close(1000, "host stopped");
1106
+ }
1107
+ this.viewers.clear();
713
1108
  // The host is going: there is no later to insist in.
714
1109
  this.holder?.end();
715
1110
  this.holder = undefined;