linkshell-cli 0.10.45 → 0.10.47

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 (44) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/src/auth.d.ts +6 -2
  3. package/dist/cli/src/auth.js +12 -4
  4. package/dist/cli/src/auth.js.map +1 -1
  5. package/dist/cli/src/commands/desktop-workspace-state.d.ts +19 -0
  6. package/dist/cli/src/commands/desktop-workspace-state.js +92 -0
  7. package/dist/cli/src/commands/desktop-workspace-state.js.map +1 -0
  8. package/dist/cli/src/commands/desktop-workspace.d.ts +12 -0
  9. package/dist/cli/src/commands/desktop-workspace.js +415 -0
  10. package/dist/cli/src/commands/desktop-workspace.js.map +1 -0
  11. package/dist/cli/src/commands/desktop.d.ts +5 -5
  12. package/dist/cli/src/commands/desktop.js +55 -5
  13. package/dist/cli/src/commands/desktop.js.map +1 -1
  14. package/dist/cli/src/commands/login.d.ts +3 -1
  15. package/dist/cli/src/commands/login.js +44 -26
  16. package/dist/cli/src/commands/login.js.map +1 -1
  17. package/dist/cli/src/index.js +15 -0
  18. package/dist/cli/src/index.js.map +1 -1
  19. package/dist/cli/src/pro-membership.d.ts +16 -0
  20. package/dist/cli/src/pro-membership.js +91 -0
  21. package/dist/cli/src/pro-membership.js.map +1 -0
  22. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  23. package/dist/client-core/src/pro-membership.d.ts +18 -0
  24. package/dist/client-core/src/pro-membership.js +109 -0
  25. package/dist/client-core/src/pro-membership.js.map +1 -0
  26. package/dist/host/src/input.d.ts +29 -2
  27. package/dist/host/src/input.js +99 -4
  28. package/dist/host/src/input.js.map +1 -1
  29. package/dist/host/src/screen-session.d.ts +35 -0
  30. package/dist/host/src/screen-session.js +26 -0
  31. package/dist/host/src/screen-session.js.map +1 -0
  32. package/dist/host/src/screen-viewer.js +293 -30
  33. package/dist/host/src/screen-viewer.js.map +1 -1
  34. package/dist/host/src/screen.d.ts +3 -1
  35. package/dist/host/src/screen.js +454 -160
  36. package/dist/host/src/screen.js.map +1 -1
  37. package/package.json +6 -4
  38. package/src/auth.ts +9 -4
  39. package/src/commands/desktop-workspace-state.ts +87 -0
  40. package/src/commands/desktop-workspace.ts +353 -0
  41. package/src/commands/desktop.ts +49 -5
  42. package/src/commands/login.ts +33 -34
  43. package/src/index.ts +16 -0
  44. package/src/pro-membership.ts +82 -0
@@ -9,6 +9,7 @@ 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 { fallbackProfile, fallbackSize, SCREEN_RETAIN_MS, screenConfiguration } from "./screen-session.js";
12
13
  // Viewing the computer's screen from the phone. The host serves a small
13
14
  // viewer page on a loopback port; the phone opens it through the same
14
15
  // encrypted forwarder as port previews. A random token gates the port: other
@@ -30,9 +31,18 @@ async function hasFfmpeg() {
30
31
  }
31
32
  /** What the phone is told when the picture can't be had for want of the permission. */
32
33
  const notAllowed = (app) => `电脑还没有允许${app ? `「${app}」` : ""}录制屏幕。在电脑上运行 linkshell screen 按提示操作,或在「系统设置 › 隐私与安全性 › 录屏与系统录音」里打开${app ? `「${app}」` : "它"}的开关,然后重新进入这个页面。`;
34
+ async function captureReleased(capture) {
35
+ let timer;
36
+ try {
37
+ await Promise.race([capture.gone, new Promise((resolve) => { timer = setTimeout(resolve, 3000); timer.unref(); })]);
38
+ }
39
+ finally {
40
+ if (timer)
41
+ clearTimeout(timer);
42
+ }
43
+ }
33
44
  /** 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);
45
+ export function captureArgs(level = 0, profile = fallbackProfile(level)) {
36
46
  const rate = (bits) => `${Math.round(bits / 1000)}k`;
37
47
  return [
38
48
  "-hide_banner",
@@ -207,6 +217,7 @@ export class ScreenShare {
207
217
  /** The viewer the app is sending the screen to as a video track. One at a time, like captures. */
208
218
  video;
209
219
  controls = new Set();
220
+ viewers = new Map();
210
221
  constructor(log,
211
222
  /** The servers both ends ask for their public address, to reach each other directly. */
212
223
  iceServers = () => []) {
@@ -229,13 +240,15 @@ export class ScreenShare {
229
240
  if (!app)
230
241
  throw RpcError.app("not_supported", NO_APP);
231
242
  const displays = await app.displays();
232
- return displays.map(({ screen, name }) => ({ index: screen, screen, name: name || `屏幕 ${screen + 1}` }));
243
+ return displays.map(({ screen, name, w, h }) => ({ index: screen, screen, name: name || `屏幕 ${screen + 1}`, w, h }));
233
244
  }
234
245
  if (process.platform !== "linux")
235
246
  throw RpcError.app("not_supported", "这台电脑的系统暂不支持查看屏幕");
236
247
  if (!(await hasFfmpeg()))
237
248
  throw RpcError.app("not_supported", "查看屏幕需要电脑上装有 ffmpeg");
238
- return [{ index: 0, name: "屏幕", screen: 0 }];
249
+ // x11grab captures the X display's root window, which may span several monitors.
250
+ const dimensions = await run("xdpyinfo", [], { timeout: 5000 }).then(({ stdout }) => /dimensions:\s+(\d+)x(\d+)\s+pixels/.exec(stdout), () => null);
251
+ return [{ index: 0, name: "屏幕", screen: 0, ...(dimensions ? { w: Number(dimensions[1]), h: Number(dimensions[2]) } : {}) }];
239
252
  }
240
253
  listen() {
241
254
  if (this.server)
@@ -310,7 +323,7 @@ export class ScreenShare {
310
323
  }
311
324
  }
312
325
  /** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
313
- async capture(screen, level, on) {
326
+ async capture(screen, level, on, profile = fallbackProfile(level), wanted = () => true) {
314
327
  let ended = false;
315
328
  let leave = () => { };
316
329
  const gone = new Promise((resolve) => (leave = resolve));
@@ -321,6 +334,8 @@ export class ScreenShare {
321
334
  return undefined;
322
335
  }
323
336
  const status = await app.access();
337
+ if (!wanted())
338
+ return undefined;
324
339
  if (!status.recording) {
325
340
  // Someone may be at the computer: the app's window for its permissions comes up there.
326
341
  void app.ask().catch(() => { });
@@ -328,9 +343,9 @@ export class ScreenShare {
328
343
  return undefined;
329
344
  }
330
345
  let size;
331
- const stream = await app.stream(screen, rung(level), {
346
+ const stream = await app.stream(screen, profile, {
332
347
  frame: (unit, key, generation) => {
333
- if (ended)
348
+ if (ended || !wanted())
334
349
  return;
335
350
  if (size !== undefined && generation !== size)
336
351
  on.resized?.();
@@ -339,37 +354,42 @@ export class ScreenShare {
339
354
  },
340
355
  exit: (error) => {
341
356
  leave();
342
- if (ended)
357
+ if (ended || !wanted())
343
358
  return;
344
359
  if (error)
345
360
  this.log(`[screen] the app's capture ended: ${error}`);
346
361
  on.exit(!error ? undefined : /permission|not allowed|not authorized|denied|declined/i.test(error) ? notAllowed(status.app) : "屏幕捕获失败");
347
362
  },
348
363
  });
364
+ const ready = stream.ready.then(({ width, height, fps }) => ({ width, height, maxFps: fps }));
365
+ void ready.catch(() => { });
349
366
  return {
350
367
  on,
351
368
  gone,
369
+ ready,
352
370
  end: () => {
353
371
  ended = true;
354
372
  stream.end();
355
373
  leave();
356
374
  },
357
- change: (next) => stream.set(rung(next)),
375
+ change: (next, requested) => stream.set(requested ?? rung(next)),
358
376
  key: () => stream.key(),
359
377
  };
360
378
  }
361
- const capture = spawn("ffmpeg", captureArgs(level), { stdio: ["ignore", "pipe", "pipe"] });
379
+ if (!wanted())
380
+ return undefined;
381
+ const capture = spawn("ffmpeg", captureArgs(level, profile), { stdio: ["ignore", "pipe", "pipe"] });
362
382
  this.captures.add(capture);
363
383
  const splitter = new AccessUnitSplitter();
364
384
  let errors = "";
365
385
  capture.stdout.on("data", (chunk) => {
366
- if (!ended)
386
+ if (!ended && wanted())
367
387
  splitter.push(chunk, on.frame);
368
388
  });
369
389
  capture.stderr.on("data", (chunk) => (errors = (errors + chunk.toString()).slice(-2000)));
370
390
  const finished = (code, said) => {
371
391
  leave();
372
- if (ended)
392
+ if (ended || !wanted())
373
393
  return;
374
394
  if (code)
375
395
  this.log(`[screen] capture exited ${code}: ${said.trim()}`);
@@ -390,8 +410,10 @@ export class ScreenShare {
390
410
  };
391
411
  }
392
412
  /** 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) {
413
+ take(screen, level, on, wanted, profile = fallbackProfile(level)) {
394
414
  const mine = this.turn.then(async () => {
415
+ if (!wanted())
416
+ return undefined;
395
417
  this.video?.taken();
396
418
  const previous = this.holder;
397
419
  if (previous) {
@@ -400,11 +422,15 @@ export class ScreenShare {
400
422
  // Another device's picture ends here; a viewer changing its own just carries on.
401
423
  if (previous.on !== on)
402
424
  previous.on.exit("屏幕画面被另一台设备接手了");
403
- await Promise.race([previous.gone, new Promise((resolve) => setTimeout(resolve, 3000))]);
425
+ await captureReleased(previous);
404
426
  }
405
427
  if (!wanted())
406
428
  return undefined;
407
- const running = await this.capture(screen, level, on);
429
+ const running = await this.capture(screen, level, on, profile, wanted);
430
+ if (!wanted()) {
431
+ running?.end();
432
+ return undefined;
433
+ }
408
434
  this.holder = running;
409
435
  return running;
410
436
  });
@@ -435,10 +461,14 @@ export class ScreenShare {
435
461
  this.video?.taken();
436
462
  const holder = this.holder;
437
463
  if (holder) {
438
- this.holder = undefined;
439
464
  holder.end();
440
465
  holder.on.exit("屏幕画面被另一台设备接手了");
466
+ await captureReleased(holder);
467
+ if (this.holder === holder)
468
+ this.holder = undefined;
441
469
  }
470
+ if (viewer.closed())
471
+ return "refused";
442
472
  const id = `video-${randomBytes(6).toString("hex")}`;
443
473
  const mine = {
444
474
  id,
@@ -480,44 +510,146 @@ export class ScreenShare {
480
510
  viewer.tell({ rtc: { ...rest, t: kind.slice(4) } });
481
511
  }
482
512
  });
483
- return { app, id };
513
+ if (viewer.closed() || this.video !== mine) {
514
+ app.endVideo(id);
515
+ if (this.video === mine)
516
+ this.video = undefined;
517
+ return "refused";
518
+ }
519
+ return { app, id, reconfigure: status.videoReconfigure === true };
484
520
  }
485
521
  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`).
522
+ // Paused viewers still own a session. A new authenticated viewer takes that ownership too.
523
+ for (const [previous, release] of this.viewers) {
524
+ if (previous.readyState === previous.OPEN)
525
+ previous.send(JSON.stringify({ error: "屏幕画面被另一台设备接手了" }));
526
+ release();
527
+ previous.close();
528
+ }
529
+ let shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
530
+ let desired = { display: shown?.index ?? 0, width: width ?? 1920, maxFps: maxFps === 30 || maxFps === 60 || maxFps === 120 ? maxFps : undefined, paused: false };
531
+ let closed = false;
532
+ const live = () => !closed && ws.readyState === ws.OPEN;
533
+ const tell = (message) => { if (live())
534
+ ws.send(JSON.stringify(message)); };
535
+ let control;
536
+ let controlRequested = false;
537
+ let inputEnabled = false;
538
+ let generationEnabled = false;
539
+ let inputGeneration = 0;
540
+ let appliedGeneration;
541
+ let revision = 0;
542
+ let lastRequestId = -1;
543
+ let desiredRequestId;
544
+ let helperRequestId = 0;
545
+ let pending;
546
+ let configuring = false;
547
+ let initialized = false;
548
+ let canConfigure = false;
549
+ let pausedExpiry;
550
+ let running;
551
+ let captureGeneration = 0;
552
+ let video;
553
+ let signals = Promise.resolve();
496
554
  const best = relayed ? RELAY_LEVEL : 0;
497
555
  const pacer = new Pacer(Date.now());
498
556
  let level = best;
499
- let closed = false;
500
557
  let changing = false;
501
558
  let lighter = false;
502
559
  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.
560
+ let wantsKey = true;
561
+ const stopControl = () => {
562
+ inputEnabled = false;
563
+ if (!control)
564
+ return;
565
+ control.stop();
566
+ this.controls.delete(control);
567
+ control = undefined;
568
+ };
569
+ const startControl = async (preparing = false) => {
570
+ if (!live() || (!inputEnabled && !preparing) || desired.paused || !controlRequested || control)
571
+ return;
572
+ const at = revision;
573
+ const created = new InputControl({
574
+ // Readiness may arrive while the applied target is still being acknowledged; it is
575
+ // safe to report permissions then, while the independent input gate stays closed.
576
+ state: (state) => { if (control === created && revision === at)
577
+ tell({ control: state }); },
578
+ cursor: (x, y) => { if (control === created && inputEnabled)
579
+ tell({ cursor: { x, y } }); },
580
+ posted: (event) => this.log(`[screen] dry run: ${JSON.stringify(event)}`),
581
+ }, this.log);
582
+ control = created;
583
+ this.controls.add(created);
584
+ await created.start(shown?.screen ?? 0);
585
+ if (!live() || control !== created || revision !== at || (!inputEnabled && !preparing)) {
586
+ created.stop();
587
+ this.controls.delete(created);
588
+ }
589
+ };
590
+ const stopRunning = () => {
591
+ captureGeneration += 1;
592
+ const stopped = running;
593
+ running = undefined;
594
+ stopped?.end();
595
+ // ffmpeg can hold the recorder until SIGKILL; the next capture must wait for it.
596
+ if (stopped && this.holder === stopped)
597
+ void stopped.gone.then(() => { if (this.holder === stopped)
598
+ this.holder = undefined; });
599
+ };
600
+ const endVideo = () => {
601
+ if (!video)
602
+ return;
603
+ const ended = video;
604
+ video = undefined;
605
+ ended.app.endVideo(ended.id);
606
+ if (this.video?.id === ended.id)
607
+ this.video = undefined;
608
+ };
609
+ const expirePaused = () => {
610
+ if (pausedExpiry)
611
+ return;
612
+ pausedExpiry = setTimeout(() => {
613
+ pausedExpiry = undefined;
614
+ if (live()) {
615
+ dispose();
616
+ ws.close(1000, "screen retention expired");
617
+ }
618
+ }, SCREEN_RETAIN_MS);
619
+ pausedExpiry.unref();
620
+ };
621
+ const resumed = () => {
622
+ if (pausedExpiry)
623
+ clearTimeout(pausedExpiry);
624
+ pausedExpiry = undefined;
625
+ };
626
+ const capabilities = () => tell({ session: { t: "capabilities", version: 1, configure: canConfigure, pause: video ? video.reconfigure : true, retainMs: SCREEN_RETAIN_MS } });
627
+ const failure = (error) => {
628
+ if (!live())
629
+ return;
630
+ this.log(`[screen] the capture did not start: ${String(error)}`);
631
+ tell({ error: "屏幕捕获失败" });
632
+ dispose();
633
+ ws.close();
634
+ };
509
635
  const askForKey = () => {
636
+ if (!live() || desired.paused || configuring)
637
+ return;
510
638
  const now = Date.now();
511
639
  if (!running?.key || now - askedForKey < 500 || !pacer.needsKey(now))
512
640
  return;
513
641
  askedForKey = now;
514
642
  running.key();
515
643
  };
516
- // A still screen sends no frame to be reminded by.
517
- const reminder = setInterval(askForKey, 500);
644
+ let reminder;
645
+ const stopReminder = () => { if (reminder)
646
+ clearInterval(reminder); reminder = undefined; };
647
+ const startReminder = () => { if (!reminder)
648
+ reminder = setInterval(askForKey, 500); };
649
+ startReminder();
518
650
  const events = {
519
651
  frame: (unit, key) => {
520
- if (ws.readyState !== ws.OPEN || unwatched)
652
+ if (!live() || desired.paused)
521
653
  return;
522
654
  if (wantsKey && !key)
523
655
  return;
@@ -532,104 +664,226 @@ export class ScreenShare {
532
664
  }
533
665
  else
534
666
  askForKey();
535
- const advice = changing ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
667
+ const advice = changing || configuring ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
536
668
  if (advice)
537
- void change(level + (advice === "down" ? 1 : -1));
669
+ void changeLevel(level + (advice === "down" ? 1 : -1));
538
670
  },
539
- // The page starts its decoder afresh for the new size; what it had of the old one came before this, in order.
540
671
  resized: () => tell({ restart: true, level, lighter }),
541
- exit: (message) => {
542
- if (message)
543
- tell({ error: message });
544
- ws.close();
545
- },
672
+ exit: (message) => { if (message)
673
+ tell({ error: message }); dispose(); ws.close(); },
546
674
  };
547
- const begin = async () => {
548
- const started = await this.take(shown?.screen ?? 0, level, events, () => !closed);
549
- if (closed)
675
+ const begin = async (at, target, configuration) => {
676
+ const captureAt = ++captureGeneration;
677
+ const wanted = () => live() && revision === at && captureGeneration === captureAt && !desired.paused && !video;
678
+ const scoped = {
679
+ frame: (unit, key) => { if (wanted())
680
+ events.frame(unit, key); },
681
+ resized: () => { if (wanted())
682
+ events.resized?.(); },
683
+ exit: (message) => { if (wanted())
684
+ events.exit(message); },
685
+ };
686
+ const started = await this.take(target?.screen ?? 0, level, scoped, wanted, fallbackProfile(level, configuration));
687
+ if (!wanted()) {
688
+ if (this.holder === started)
689
+ this.holder = undefined;
550
690
  started?.end();
551
- else
691
+ }
692
+ else {
552
693
  running = started;
694
+ if (started?.ready) {
695
+ let timer;
696
+ try {
697
+ const size = await Promise.race([started.ready, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("屏幕捕获启动超时")), 15_000); timer.unref(); })]);
698
+ if (wanted())
699
+ return size;
700
+ }
701
+ catch (error) {
702
+ if (wanted())
703
+ throw error;
704
+ }
705
+ finally {
706
+ if (timer)
707
+ clearTimeout(timer);
708
+ }
709
+ }
710
+ }
711
+ return undefined;
553
712
  };
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) => {
713
+ const changeLevel = async (next) => {
714
+ if (!live() || desired.paused || configuring || changing)
715
+ return;
560
716
  changing = true;
561
717
  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`);
718
+ level = next;
564
719
  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;
720
+ running.change(next, fallbackProfile(next, desired));
568
721
  pacer.changed(Date.now());
569
- changing = false;
570
- return;
571
722
  }
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());
723
+ else {
724
+ stopRunning();
725
+ tell({ restart: true, level, lighter });
726
+ await begin(revision, shown, desired).catch(failure);
727
+ pacer.restarted(Date.now());
728
+ }
578
729
  changing = false;
579
730
  };
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)
731
+ const current = (change) => live() && revision === change.revision;
732
+ const drain = async () => {
733
+ if (!initialized || configuring || !live())
586
734
  return;
587
- video.app.endVideo(video.id);
588
- if (this.video?.id === video.id)
589
- this.video = undefined;
590
- video = undefined;
735
+ configuring = true;
736
+ try {
737
+ while (pending && live()) {
738
+ const change = pending;
739
+ pending = undefined;
740
+ const configuration = change.configuration;
741
+ try {
742
+ if (!canConfigure && change.requestId !== undefined)
743
+ throw new Error("电脑上的屏幕组件不支持保留连接,请重新连接");
744
+ // A monitor may have been unplugged since screen.start listed it.
745
+ const displays = await this.listDisplays();
746
+ if (!current(change))
747
+ continue;
748
+ this.displays = displays;
749
+ const target = displays.find((entry) => entry.index === configuration.display);
750
+ if (!target)
751
+ throw new Error("所选屏幕已不可用,请选择其他屏幕");
752
+ let applied;
753
+ if (video) {
754
+ if (!video.reconfigure) {
755
+ // An old helper cannot stop capture without closing its peer connection.
756
+ if (configuration.paused) {
757
+ dispose();
758
+ ws.close();
759
+ return;
760
+ }
761
+ throw new Error("电脑上的屏幕组件不支持保留连接,请重新连接");
762
+ }
763
+ applied = await video.app.configureVideo(video.id, {
764
+ requestId: ++helperRequestId, screen: target.screen, maxWidth: configuration.width,
765
+ ...(configuration.maxFps === undefined ? {} : { maxFps: configuration.maxFps }), paused: configuration.paused,
766
+ ...(change.generation === undefined ? {} : { inputGeneration: change.generation }),
767
+ });
768
+ if (applied.screen !== target.screen || applied.paused !== configuration.paused || (change.generation !== undefined && applied.generation !== change.generation)) {
769
+ throw new Error("电脑返回的屏幕设置无效,请重试");
770
+ }
771
+ }
772
+ else {
773
+ const profile = fallbackProfile(level, configuration);
774
+ let size = !target.w || !target.h ? { width: 0, height: 0 } : fallbackSize(target, profile);
775
+ let fps = profile.fps;
776
+ if ((!size.width || !size.height) && change.requestId !== undefined)
777
+ throw new Error("无法读取当前屏幕尺寸,请重新连接");
778
+ stopRunning();
779
+ if (!configuration.paused) {
780
+ wantsKey = true;
781
+ pacer.restarted(Date.now());
782
+ tell({ restart: true, level, lighter: false });
783
+ const started = await begin(change.revision, target, configuration);
784
+ if (started) {
785
+ size = started;
786
+ fps = started.maxFps;
787
+ }
788
+ }
789
+ applied = { screen: target.screen, ...size, maxFps: fps, paused: configuration.paused, generation: change.generation };
790
+ }
791
+ if (!current(change))
792
+ continue;
793
+ shown = target;
794
+ appliedGeneration = change.generation;
795
+ if (!configuration.paused)
796
+ await startControl(true);
797
+ if (!current(change))
798
+ continue;
799
+ inputEnabled = !configuration.paused;
800
+ if (!configuration.paused) {
801
+ resumed();
802
+ startReminder();
803
+ }
804
+ if (change.requestId !== undefined)
805
+ tell({ session: {
806
+ t: "configured", requestId: change.requestId, display: target.index, width: applied.width, height: applied.height,
807
+ maxFps: applied.maxFps, paused: applied.paused,
808
+ ...(applied.generation === undefined ? {} : { generation: applied.generation }),
809
+ } });
810
+ }
811
+ catch (error) {
812
+ if (!current(change))
813
+ continue;
814
+ stopControl();
815
+ if (!video)
816
+ stopRunning();
817
+ this.log(`[screen] configuration failed: ${String(error)}`);
818
+ if (change.requestId !== undefined)
819
+ tell({ session: { t: "error", requestId: change.requestId, message: "屏幕设置未能应用,请重试" } });
820
+ else if (configuration.paused) {
821
+ dispose();
822
+ ws.close();
823
+ }
824
+ }
825
+ }
826
+ }
827
+ finally {
828
+ configuring = false;
829
+ }
591
830
  };
592
- const fallBack = () => {
593
- if (!video)
594
- return;
831
+ const configure = (configuration, requestId) => {
832
+ desired = configuration;
833
+ if (requestId !== undefined) {
834
+ generationEnabled = true;
835
+ desiredRequestId = requestId;
836
+ }
837
+ const at = ++revision;
838
+ pending = { configuration, revision: at, requestId, ...(generationEnabled ? { generation: ++inputGeneration } : {}) };
839
+ stopControl();
840
+ if (configuration.paused) {
841
+ stopReminder();
842
+ stopRunning();
843
+ expirePaused();
844
+ }
845
+ void drain();
846
+ };
847
+ const startFallback = () => {
595
848
  endVideo();
596
- if (closed)
849
+ if (!live())
597
850
  return;
598
851
  tell({ rtc: { t: "off" } });
599
- void begin().catch(failed);
852
+ canConfigure = !!shown?.w && !!shown?.h;
853
+ initialized = true;
854
+ capabilities();
855
+ if (pending || desiredRequestId !== undefined || desired.paused) {
856
+ configure(desired, desiredRequestId);
857
+ }
858
+ else {
859
+ const at = revision;
860
+ void begin(at, shown, desired).then(() => {
861
+ if (live() && revision === at && !desired.paused) {
862
+ inputEnabled = true;
863
+ void startControl();
864
+ }
865
+ }).catch(failure);
866
+ }
600
867
  };
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);
868
+ const fallBack = () => { if (video)
869
+ startFallback(); };
870
+ const dispose = () => {
871
+ if (closed)
872
+ return;
873
+ closed = true;
874
+ revision += 1;
875
+ pending = undefined;
876
+ stopReminder();
877
+ resumed();
878
+ stopRunning();
879
+ endVideo();
880
+ stopControl();
881
+ this.viewers.delete(ws);
882
+ };
883
+ this.viewers.set(ws, dispose);
884
+ ws.on("close", dispose);
631
885
  ws.on("message", (data, binary) => {
632
- if (binary)
886
+ if (binary || !live())
633
887
  return;
634
888
  let message;
635
889
  try {
@@ -639,77 +893,117 @@ export class ScreenShare {
639
893
  return;
640
894
  }
641
895
  const kind = message?.t;
642
- if (kind === "ack")
896
+ if (kind === "screen.configure") {
897
+ const parsed = screenConfiguration.safeParse(message);
898
+ if (!parsed.success || !this.displays.some((entry) => entry.index === parsed.data.display)) {
899
+ const requestId = message.requestId;
900
+ tell({ session: { t: "error", ...(typeof requestId === "number" && Number.isSafeInteger(requestId) && requestId >= 0 ? { requestId } : {}), message: "屏幕设置无效,请检查屏幕、分辨率和帧率" } });
901
+ return;
902
+ }
903
+ if (parsed.data.requestId <= lastRequestId)
904
+ return;
905
+ if (initialized && !canConfigure) {
906
+ tell({ session: { t: "error", requestId: parsed.data.requestId, message: "电脑上的屏幕组件不支持保留连接,请重新连接" } });
907
+ return;
908
+ }
909
+ lastRequestId = parsed.data.requestId;
910
+ const { t: _type, requestId, ...configuration } = parsed.data;
911
+ configure(configuration, requestId);
912
+ }
913
+ else if (kind === "hidden" || kind === "shown") {
914
+ const paused = kind === "hidden";
915
+ if (desired.paused !== paused)
916
+ configure({ ...desired, paused });
917
+ }
918
+ else if (kind === "ack")
643
919
  pacer.ack(Number(message.n), Date.now());
644
920
  else if (kind === "rtc.answer" || kind === "rtc.ice") {
645
921
  const signal = videoSignal.safeParse(message);
646
922
  if (!signal.success)
647
923
  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)
924
+ signals = signals.then(async () => {
925
+ const target = video;
926
+ if (!target || !live())
654
927
  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(() => { });
928
+ const data = signal.data;
929
+ const resolved = data.t === "rtc.ice" ? { ...data, candidate: await resolvedCandidate(data.candidate) } : data;
930
+ if (live() && video === target)
931
+ target.app.signal(target.id, resolved);
932
+ }).catch(() => { });
659
933
  }
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
934
  else if (kind === "keyframe") {
663
935
  const now = Date.now();
664
- if (running?.key && now - askedForKey >= 500) {
936
+ if (!desired.paused && !configuring && running?.key && now - askedForKey >= 500) {
665
937
  askedForKey = now;
666
938
  running.key();
667
939
  }
668
940
  }
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
941
  else if (kind === "rtc.diagnosis") {
682
942
  const { reason, ice } = message;
683
943
  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
944
  }
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`);
945
+ else if (kind === "rtc.failed")
688
946
  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
947
  else if (kind === "ping")
693
948
  tell({ pong: { n: message.n, at: message.at, now: Date.now() } });
694
949
  else if (kind === "measure")
695
950
  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);
951
+ else if (kind === "control" && !desired.paused) {
952
+ // Requesting control only records intent. Input still needs the applied generation,
953
+ // and startup/configuration must finish before the helper can be joined.
954
+ controlRequested = true;
955
+ void startControl();
956
+ }
957
+ else if (!desired.paused && inputEnabled && (!generationEnabled || message?.screenGeneration === appliedGeneration)) {
958
+ control?.send(message);
959
+ }
710
960
  });
961
+ if (wantsVideo) {
962
+ let lostWhileOpening = false;
963
+ void this.offerVideo(shown?.screen ?? 0, width, maxFps, diagnostics, codec, {
964
+ closed: () => !live(), tell,
965
+ refuse: (message) => { tell({ error: message }); dispose(); ws.close(); },
966
+ lost: () => { if (video)
967
+ fallBack();
968
+ else
969
+ lostWhileOpening = true; },
970
+ }).then((offered) => {
971
+ if (offered === "refused")
972
+ return;
973
+ if (!live()) {
974
+ if (offered)
975
+ offered.app.endVideo(offered.id);
976
+ return;
977
+ }
978
+ if (!offered) {
979
+ startFallback();
980
+ return;
981
+ }
982
+ video = offered;
983
+ if (lostWhileOpening) {
984
+ startFallback();
985
+ return;
986
+ }
987
+ initialized = true;
988
+ canConfigure = offered.reconfigure;
989
+ capabilities();
990
+ if (pending)
991
+ void drain();
992
+ else {
993
+ inputEnabled = true;
994
+ void startControl();
995
+ }
996
+ }).catch(failure);
997
+ }
998
+ else
999
+ startFallback();
711
1000
  }
712
1001
  stop() {
1002
+ for (const [viewer, release] of this.viewers) {
1003
+ release();
1004
+ viewer.close(1000, "host stopped");
1005
+ }
1006
+ this.viewers.clear();
713
1007
  // The host is going: there is no later to insist in.
714
1008
  this.holder?.end();
715
1009
  this.holder = undefined;