linkshell-cli 0.10.46 → 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.
- package/dist/cli/src/auth.d.ts +6 -2
- package/dist/cli/src/auth.js +9 -5
- package/dist/cli/src/auth.js.map +1 -1
- package/dist/cli/src/commands/desktop-workspace-state.d.ts +19 -0
- package/dist/cli/src/commands/desktop-workspace-state.js +92 -0
- package/dist/cli/src/commands/desktop-workspace-state.js.map +1 -0
- package/dist/cli/src/commands/desktop-workspace.d.ts +12 -0
- package/dist/cli/src/commands/desktop-workspace.js +415 -0
- package/dist/cli/src/commands/desktop-workspace.js.map +1 -0
- package/dist/cli/src/commands/login.d.ts +3 -1
- package/dist/cli/src/commands/login.js +35 -10
- package/dist/cli/src/commands/login.js.map +1 -1
- package/dist/cli/src/index.js +15 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/client-core/src/pro-membership.d.ts +1 -0
- package/dist/client-core/src/pro-membership.js +3 -1
- package/dist/client-core/src/pro-membership.js.map +1 -1
- package/dist/host/src/input.d.ts +29 -2
- package/dist/host/src/input.js +99 -4
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/screen-session.d.ts +35 -0
- package/dist/host/src/screen-session.js +26 -0
- package/dist/host/src/screen-session.js.map +1 -0
- package/dist/host/src/screen-viewer.js +293 -30
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +3 -1
- package/dist/host/src/screen.js +454 -160
- package/dist/host/src/screen.js.map +1 -1
- package/package.json +6 -4
- package/src/auth.ts +7 -5
- package/src/commands/desktop-workspace-state.ts +87 -0
- package/src/commands/desktop-workspace.ts +353 -0
- package/src/commands/login.ts +27 -10
- package/src/index.ts +16 -0
package/dist/host/src/screen.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
517
|
-
const
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
549
|
-
|
|
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
|
-
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
593
|
-
|
|
594
|
-
|
|
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 (
|
|
849
|
+
if (!live())
|
|
597
850
|
return;
|
|
598
851
|
tell({ rtc: { t: "off" } });
|
|
599
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 === "
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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;
|