@linkshell/host 0.3.1 → 0.4.0

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.
@@ -4,71 +4,46 @@ import { createServer } from "node:http";
4
4
  import { promisify } from "node:util";
5
5
  import { WebSocketServer } from "ws";
6
6
  import { RpcError } from "@linkshell/wire";
7
- import { closeInputApp, hostAccess, inputApp, InputControl } from "./input.js";
7
+ import { closeInputApp, inputApp, InputControl, NO_APP, videoSignal } from "./input.js";
8
+ import { LADDER, Pacer, RELAY_LEVEL, rung } from "./screen-pacer.js";
8
9
  import { viewerPage } from "./screen-viewer.js";
9
10
  // Viewing the computer's screen from the phone. The host serves a small
10
- // viewer page and an H.264 stream on a loopback port; the phone opens it
11
- // through the same encrypted forwarder as port previews, so the video never
12
- // leaves the end-to-end channel and needs no NAT traversal. A random token
13
- // gates the port: other programs on this machine can't watch the screen
14
- // without having Screen Recording permission themselves. The same socket
15
- // carries the viewer's pointer and key events back (see `input.ts`).
11
+ // viewer page on a loopback port; the phone opens it through the same
12
+ // encrypted forwarder as port previews. A random token gates the port: other
13
+ // programs on this machine can't watch the screen without having Screen
14
+ // Recording permission themselves.
16
15
  //
17
- // On a Mac the system allows recording to an app by name. A released package
18
- // carries one, LinkShell.app, and the capture runs as its child: the
19
- // permission is LinkShell's, whichever terminal started the host.
16
+ // On a Mac the picture is LinkShell.app's (apps/mac; see `input.ts`), which
17
+ // holds the system's permissions under its own name. Where the app and the
18
+ // viewer can reach each other, it is a video track the app sends the viewer
19
+ // directly (docs/v2/screen-realtime.md): the page's socket then carries only
20
+ // what the two need to find each other. Where they can't, the app encodes the
21
+ // picture for the socket instead, and the host paces it (`screen-pacer.ts`):
22
+ // H.264 frames down a pipe that loses nothing, through the forwarder, with the
23
+ // viewer's pointer and key events coming back up it. On Linux that second way
24
+ // is the only one, with ffmpeg capturing.
20
25
  const run = promisify(execFile);
21
- /**
22
- * How the picture is sent. `full` when it goes straight to the device; `low`
23
- * when it is relayed by a gateway, which should carry messages rather than video.
24
- */
25
- const PROFILES = {
26
- full: { fps: 20, width: 1600, bitrate: "3M", ceiling: "4M" },
27
- low: { fps: 12, width: 1280, bitrate: "900k", ceiling: "1300k" },
28
- };
29
26
  async function hasFfmpeg() {
30
27
  return run("ffmpeg", ["-hide_banner", "-version"], { timeout: 5000 }).then(() => true, () => false);
31
28
  }
32
- /** Where ffmpeg is: an app the system opened has no shell's PATH to find it on. */
33
- async function ffmpegPath() {
34
- return run("/usr/bin/which", ["ffmpeg"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || "ffmpeg", () => "ffmpeg");
35
- }
36
29
  /** What the phone is told when the picture can't be had for want of the permission. */
37
30
  const notAllowed = (app) => `电脑还没有允许${app ? `「${app}」` : ""}录制屏幕。在电脑上运行 linkshell screen 按提示操作,或在「系统设置 › 隐私与安全性 › 录屏与系统录音」里打开${app ? `「${app}」` : "它"}的开关,然后重新进入这个页面。`;
38
- /** macOS: AVFoundation's "Capture screen N" devices. */
39
- async function listDisplays() {
40
- if (process.platform !== "darwin")
41
- return [{ index: 0, name: "屏幕", screen: 0 }];
42
- const output = await run("ffmpeg", ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], { timeout: 8000 }).then(({ stderr }) => stderr, (error) => error.stderr ?? "");
43
- const displays = [];
44
- for (const match of output.matchAll(/\[(\d+)\] Capture screen (\d+)/g)) {
45
- displays.push({ index: Number(match[1]), name: `屏幕 ${Number(match[2]) + 1}`, screen: Number(match[2]) });
46
- }
47
- return displays;
48
- }
49
- export function captureArgs(display, quality = "full") {
50
- const profile = PROFILES[quality];
51
- const input = process.platform === "darwin"
52
- ? ["-f", "avfoundation", "-capture_cursor", "1", "-framerate", String(profile.fps), "-i", `${display}:none`]
53
- : ["-f", "x11grab", "-framerate", String(profile.fps), "-i", process.env.DISPLAY ?? ":0"];
54
- const encode = process.platform === "darwin"
55
- ? ["-c:v", "h264_videotoolbox", "-realtime", "1", "-profile:v", "baseline", "-b:v", profile.bitrate, "-maxrate", profile.ceiling]
56
- : [
57
- "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-x264-params", "sliced-threads=0",
58
- "-b:v", profile.bitrate, "-maxrate", profile.ceiling, "-bufsize", profile.ceiling,
59
- ];
31
+ /** ffmpeg's capture of an X display (Linux), for one rung of the ladder (see `screen-pacer.ts`): 0 is the best picture. */
32
+ export function captureArgs(level = 0) {
33
+ const profile = rung(level);
34
+ const rate = (bits) => `${Math.round(bits / 1000)}k`;
60
35
  return [
61
36
  "-hide_banner",
62
37
  "-loglevel",
63
38
  "error",
64
- ...input,
39
+ "-f", "x11grab", "-framerate", String(profile.fps), "-i", process.env.DISPLAY ?? ":0",
65
40
  "-vf",
66
- // AVFoundation captures a screen at its refresh rate whatever -framerate asks (120 a second on a
67
- // ProMotion display): the frames are dropped here, before the encoder and the network pay for them.
68
41
  `fps=${profile.fps},scale='min(${profile.width},iw)':-2,format=yuv420p`,
69
- ...encode,
42
+ "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-x264-params", "sliced-threads=0",
43
+ "-b:v", rate(profile.bitrate), "-maxrate", rate(profile.ceiling), "-bufsize", rate(profile.ceiling),
44
+ // A keyframe a second: a viewer that fell behind is back on the live picture within one.
70
45
  "-g",
71
- String(profile.fps * 2),
46
+ String(profile.fps),
72
47
  "-bf",
73
48
  "0",
74
49
  // An access-unit delimiter before every frame, so the stream splits into frames cleanly.
@@ -115,10 +90,7 @@ export class AccessUnitSplitter {
115
90
  return false;
116
91
  }
117
92
  }
118
- /**
119
- * Ends a capture. ffmpeg holding a screen through avfoundation can sit through
120
- * SIGTERM — and through its output going away — so it is asked, then made to.
121
- */
93
+ /** Ends a capture. ffmpeg holding a screen can sit through SIGTERM — and through its output going away — so it is asked, then made to. */
122
94
  export function endCapture(capture, now = false) {
123
95
  if (capture.exitCode !== null || capture.signalCode !== null)
124
96
  return;
@@ -168,23 +140,30 @@ export async function reapOrphanCaptures(log) {
168
140
  /** The loopback viewer server; started on first use, one capture per watching device. */
169
141
  export class ScreenShare {
170
142
  log;
143
+ iceServers;
171
144
  server;
172
145
  token = "";
173
146
  displays = [];
174
147
  captures = new Set();
175
- /** Captures the app runs for this host: each one's way to end it. */
176
- stops = new Set();
148
+ /**
149
+ * The capture that has the screen. The system gives it to one at a time, and
150
+ * one started while another still holds it never gets a frame, even after
151
+ * the other has gone: the next starts once this one has.
152
+ */
153
+ holder;
154
+ turn = Promise.resolve();
155
+ /** The viewer the app is sending the screen to as a video track. One at a time, like captures. */
156
+ video;
177
157
  controls = new Set();
178
- constructor(log) {
158
+ constructor(log,
159
+ /** The servers both ends ask for their public address, to reach each other directly. */
160
+ iceServers = () => []) {
179
161
  this.log = log;
162
+ this.iceServers = iceServers;
180
163
  void reapOrphanCaptures(log);
181
164
  }
182
165
  async start() {
183
- if (process.platform !== "darwin" && process.platform !== "linux")
184
- throw RpcError.app("not_supported", "这台电脑的系统暂不支持查看屏幕");
185
- if (!(await hasFfmpeg()))
186
- throw RpcError.app("not_supported", "查看屏幕需要电脑上装有 ffmpeg(brew install ffmpeg)");
187
- const displays = (this.displays = await listDisplays());
166
+ const displays = (this.displays = await this.listDisplays());
188
167
  if (!displays.length)
189
168
  throw RpcError.app("not_supported", "没有找到可以捕获的屏幕");
190
169
  // A fresh token per start: an old viewer URL stops working.
@@ -192,6 +171,20 @@ export class ScreenShare {
192
171
  const port = await this.listen();
193
172
  return { port, token: this.token, displays: displays.map(({ index, name }) => ({ index, name })) };
194
173
  }
174
+ async listDisplays() {
175
+ if (process.platform === "darwin") {
176
+ const app = inputApp(this.log);
177
+ if (!app)
178
+ throw RpcError.app("not_supported", NO_APP);
179
+ const displays = await app.displays();
180
+ return displays.map(({ screen, name }) => ({ index: screen, screen, name: name || `屏幕 ${screen + 1}` }));
181
+ }
182
+ if (process.platform !== "linux")
183
+ throw RpcError.app("not_supported", "这台电脑的系统暂不支持查看屏幕");
184
+ if (!(await hasFfmpeg()))
185
+ throw RpcError.app("not_supported", "查看屏幕需要电脑上装有 ffmpeg");
186
+ return [{ index: 0, name: "屏幕", screen: 0 }];
187
+ }
195
188
  listen() {
196
189
  if (this.server)
197
190
  return Promise.resolve(this.server.address().port);
@@ -201,6 +194,12 @@ export class ScreenShare {
201
194
  response.writeHead(403).end();
202
195
  return;
203
196
  }
197
+ // Measuring (LINKSHELL_SCREEN_CLOCK=1): every viewer reads the clock the app shows, whoever made its address.
198
+ if (process.env.LINKSHELL_SCREEN_CLOCK === "1" && !url.searchParams.has("measure")) {
199
+ url.searchParams.set("measure", "sync");
200
+ response.writeHead(302, { location: `${url.pathname}${url.search}`, "cache-control": "no-store" }).end();
201
+ return;
202
+ }
204
203
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(viewerPage());
205
204
  });
206
205
  const sockets = new WebSocketServer({ noServer: true });
@@ -210,8 +209,11 @@ export class ScreenShare {
210
209
  socket.destroy();
211
210
  return;
212
211
  }
213
- const quality = url.searchParams.get("q") === "low" ? "low" : "full";
214
- sockets.handleUpgrade(request, socket, head, (ws) => this.stream(ws, Number(url.searchParams.get("display") ?? NaN), quality));
212
+ // "low": the viewer is reached through a gateway, which should carry messages rather than video.
213
+ const relayed = url.searchParams.get("q") === "low";
214
+ // "video": the viewer can take the picture as a video track.
215
+ const video = url.searchParams.get("video") === "1";
216
+ sockets.handleUpgrade(request, socket, head, (ws) => this.stream(ws, Number(url.searchParams.get("display") ?? NaN), relayed, video));
215
217
  });
216
218
  this.server = server;
217
219
  return new Promise((resolve, reject) => {
@@ -225,74 +227,202 @@ export class ScreenShare {
225
227
  return this.token.length > 0 && given.length === expected.length && timingSafeEqual(given, expected);
226
228
  }
227
229
  /**
228
- * Whether the screen can be watched and controlled, as whatever will do it
229
- * sees it: the app where the package has one, this process otherwise. With
230
- * `ask`, the system asks for the first permission missing.
230
+ * Whether the screen can be watched and controlled. On a Mac that is the
231
+ * app's to say; with `ask`, the app opens its window for what is missing.
232
+ * (`ffmpeg`: whether what captures is there — ffmpeg itself on Linux.)
231
233
  */
232
234
  async access(ask) {
233
235
  const supported = process.platform === "darwin" || process.platform === "linux";
234
- const ffmpeg = supported && (await hasFfmpeg());
235
236
  if (process.platform !== "darwin")
236
- return { supported, ffmpeg, recording: null, control: null, problem: "controlling the screen needs macOS" };
237
+ return { supported, ffmpeg: supported && (await hasFfmpeg()), recording: null, control: null, problem: "controlling the screen needs macOS" };
237
238
  const app = inputApp(this.log);
239
+ if (!app) {
240
+ const problem = process.arch === "arm64"
241
+ ? "LinkShell.app is missing from this installation (the optional package @linkshell/mac was not installed): reinstall with `npm install -g linkshell-cli`"
242
+ : "the screen needs a Mac with Apple silicon (this one is Intel)";
243
+ return { supported: process.arch === "arm64", ffmpeg: false, recording: null, control: null, problem };
244
+ }
238
245
  try {
239
- const status = app ? await app.access() : await hostAccess(this.log);
240
- // One question at a time: the picture first, then the hands.
241
- const missing = !status.recording ? "recording" : !status.trusted ? "control" : undefined;
242
- if (ask && missing)
243
- await (app ? app.ask(missing) : hostAccess(this.log, missing));
244
- return { supported, ffmpeg, recording: status.recording, control: status.trusted, app: status.app };
246
+ const status = await app.access();
247
+ if (ask && (!status.recording || !status.trusted))
248
+ await app.ask();
249
+ return { supported, ffmpeg: true, recording: status.recording, control: status.trusted, app: status.app };
245
250
  }
246
251
  catch (error) {
247
- return { supported, ffmpeg, recording: null, control: null, problem: error.message };
252
+ return { supported, ffmpeg: true, recording: null, control: null, problem: error.message };
248
253
  }
249
254
  }
250
- /** Starts a capture where it may record; resolves to the way to end it. */
251
- async capture(index, quality, on) {
252
- const args = captureArgs(index, quality);
253
- const finished = (code, errors) => {
254
- if (code)
255
- this.log(`[screen] capture exited ${code}: ${errors.trim()}`);
256
- on.exit(!code ? undefined : /permission|not authorized|denied/i.test(errors) ? notAllowed("") : "屏幕捕获失败");
257
- };
258
- const app = process.platform === "darwin" ? inputApp(this.log) : undefined;
259
- if (app) {
255
+ /** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
256
+ async capture(screen, level, on) {
257
+ let ended = false;
258
+ let leave = () => { };
259
+ const gone = new Promise((resolve) => (leave = resolve));
260
+ if (process.platform === "darwin") {
261
+ const app = inputApp(this.log);
262
+ if (!app) {
263
+ on.exit(NO_APP);
264
+ return undefined;
265
+ }
260
266
  const status = await app.access();
261
267
  if (!status.recording) {
262
- // Someone may be at the computer: the system's question, and its settings, come up there.
263
- void app.ask("recording").catch(() => { });
268
+ // Someone may be at the computer: the app's window for its permissions comes up there.
269
+ void app.ask().catch(() => { });
264
270
  on.exit(notAllowed(status.app));
265
- return () => { };
271
+ return undefined;
266
272
  }
267
- const stop = await app.capture(await ffmpegPath(), args, { data: on.data, exit: finished });
268
- this.stops.add(stop);
269
- return () => {
270
- this.stops.delete(stop);
271
- stop();
273
+ let size;
274
+ const stream = await app.stream(screen, rung(level), {
275
+ frame: (unit, key, generation) => {
276
+ if (ended)
277
+ return;
278
+ if (size !== undefined && generation !== size)
279
+ on.resized?.();
280
+ size = generation;
281
+ on.frame(unit, key);
282
+ },
283
+ exit: (error) => {
284
+ leave();
285
+ if (ended)
286
+ return;
287
+ if (error)
288
+ this.log(`[screen] the app's capture ended: ${error}`);
289
+ on.exit(!error ? undefined : /permission|not allowed|not authorized|denied|declined/i.test(error) ? notAllowed(status.app) : "屏幕捕获失败");
290
+ },
291
+ });
292
+ return {
293
+ on,
294
+ gone,
295
+ end: () => {
296
+ ended = true;
297
+ stream.end();
298
+ leave();
299
+ },
300
+ change: (next) => stream.set(rung(next)),
301
+ key: () => stream.key(),
272
302
  };
273
303
  }
274
- if (process.platform === "darwin") {
275
- // Without the permission the capture just waits, and so would the phone.
276
- const status = await hostAccess(this.log).catch(() => undefined);
277
- if (status && !status.recording) {
278
- void hostAccess(this.log, "recording").catch(() => { });
279
- on.exit(notAllowed(status.app));
280
- return () => { };
281
- }
282
- }
283
- const capture = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
304
+ const capture = spawn("ffmpeg", captureArgs(level), { stdio: ["ignore", "pipe", "pipe"] });
284
305
  this.captures.add(capture);
306
+ const splitter = new AccessUnitSplitter();
285
307
  let errors = "";
286
- capture.stdout.on("data", on.data);
308
+ capture.stdout.on("data", (chunk) => {
309
+ if (!ended)
310
+ splitter.push(chunk, on.frame);
311
+ });
287
312
  capture.stderr.on("data", (chunk) => (errors = (errors + chunk.toString()).slice(-2000)));
313
+ const finished = (code, said) => {
314
+ leave();
315
+ if (ended)
316
+ return;
317
+ if (code)
318
+ this.log(`[screen] capture exited ${code}: ${said.trim()}`);
319
+ on.exit(code ? "屏幕捕获失败" : undefined);
320
+ };
288
321
  capture.on("error", (error) => finished(-1, error.message));
289
322
  capture.on("exit", (code) => {
290
323
  this.captures.delete(capture);
291
324
  finished(code ?? 0, errors);
292
325
  });
293
- return () => endCapture(capture);
326
+ return {
327
+ on,
328
+ gone,
329
+ end: () => {
330
+ ended = true;
331
+ endCapture(capture);
332
+ },
333
+ };
334
+ }
335
+ /** Takes the screen for a new capture: whatever holds it is ended, and has left, before the new one starts. */
336
+ take(screen, level, on, wanted) {
337
+ const mine = this.turn.then(async () => {
338
+ this.video?.taken();
339
+ const previous = this.holder;
340
+ if (previous) {
341
+ this.holder = undefined;
342
+ previous.end();
343
+ // Another device's picture ends here; a viewer changing its own just carries on.
344
+ if (previous.on !== on)
345
+ previous.on.exit("屏幕画面被另一台设备接手了");
346
+ await Promise.race([previous.gone, new Promise((resolve) => setTimeout(resolve, 3000))]);
347
+ }
348
+ if (!wanted())
349
+ return undefined;
350
+ const running = await this.capture(screen, level, on);
351
+ this.holder = running;
352
+ return running;
353
+ });
354
+ this.turn = mine.catch(() => { });
355
+ return mine;
356
+ }
357
+ /**
358
+ * Has the app offer the screen to this viewer as a video track. False when it can't (no app, an
359
+ * app without the media engine): the picture then goes down the socket.
360
+ */
361
+ async offerVideo(screen, viewer) {
362
+ // LINKSHELL_SCREEN_VIDEO=off: every viewer gets the picture down the socket (to compare the two, or should the track ever misbehave).
363
+ const app = process.platform === "darwin" && process.env.LINKSHELL_SCREEN_VIDEO !== "off" ? inputApp(this.log) : undefined;
364
+ if (!app)
365
+ return undefined;
366
+ const status = await app.access();
367
+ if (!status.video)
368
+ return undefined;
369
+ if (!status.recording) {
370
+ // Someone may be at the computer: the system's question, and its settings, come up there.
371
+ void app.ask().catch(() => { });
372
+ viewer.refuse(notAllowed(status.app));
373
+ return "refused";
374
+ }
375
+ if (viewer.closed())
376
+ return "refused";
377
+ // The latest viewer has the screen, whichever way the one before was getting it.
378
+ this.video?.taken();
379
+ const holder = this.holder;
380
+ if (holder) {
381
+ this.holder = undefined;
382
+ holder.end();
383
+ holder.on.exit("屏幕画面被另一台设备接手了");
384
+ }
385
+ const id = `video-${randomBytes(6).toString("hex")}`;
386
+ const mine = {
387
+ id,
388
+ taken: () => {
389
+ if (this.video === mine)
390
+ this.video = undefined;
391
+ app.endVideo(id);
392
+ viewer.refuse("屏幕画面被另一台设备接手了");
393
+ },
394
+ };
395
+ this.video = mine;
396
+ let reached;
397
+ const iceServers = this.iceServers().map((url) => ({ urls: [url] }));
398
+ viewer.tell({ rtc: { t: "config", iceServers } });
399
+ // LINKSHELL_SCREEN_FPS: another frame rate than the app's own choice, to try one out.
400
+ const fps = Number(process.env.LINKSHELL_SCREEN_FPS);
401
+ await app.offerVideo(id, { screen, iceServers, ...(fps >= 1 && fps <= 120 ? { fps } : {}) }, (message) => {
402
+ if (this.video !== mine)
403
+ return;
404
+ const kind = String(message.t);
405
+ if (kind === "gone" || kind === "rtc.error") {
406
+ this.log(`[screen] the video track ended: ${kind === "gone" ? "LinkShell.app went" : String(message.message)}`);
407
+ viewer.lost();
408
+ }
409
+ else if (kind === "posted") {
410
+ // A dry run: what the viewer's events, come to the app directly, would have done.
411
+ this.log(`[screen] dry run: ${JSON.stringify(message)}`);
412
+ }
413
+ else if (kind === "rtc.offer" || kind === "rtc.ice" || kind === "rtc.state") {
414
+ if (kind === "rtc.state" && message.connection !== reached) {
415
+ reached = message.connection;
416
+ if (reached === "connected")
417
+ this.log("[screen] the picture is a video track, straight to the viewer");
418
+ }
419
+ const { v: _viewer, t: _kind, ...rest } = message;
420
+ viewer.tell({ rtc: { ...rest, t: kind.slice(4) } });
421
+ }
422
+ });
423
+ return { app, id };
294
424
  }
295
- stream(ws, display, quality) {
425
+ stream(ws, display, relayed, wantsVideo) {
296
426
  const shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
297
427
  const tell = (message) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(message));
298
428
  // The viewer's hands: started when it first asks to control, for the display it is watching.
@@ -302,63 +432,213 @@ export class ScreenShare {
302
432
  posted: (event) => this.log(`[screen] dry run: ${JSON.stringify(event)}`),
303
433
  }, this.log);
304
434
  this.controls.add(control);
305
- ws.on("message", (data, binary) => {
306
- if (binary)
307
- return;
308
- let message;
309
- try {
310
- message = JSON.parse(String(data));
311
- }
312
- catch {
313
- return;
314
- }
315
- if (message?.t === "control")
316
- void control.start(shown?.screen ?? 0);
317
- else
318
- control.send(message);
319
- });
320
- const splitter = new AccessUnitSplitter();
435
+ // The picture: as good as the path carries without falling behind (see `screen-pacer.ts`).
436
+ const best = relayed ? RELAY_LEVEL : 0;
437
+ const pacer = new Pacer(Date.now());
438
+ let level = best;
321
439
  let closed = false;
322
- let stop;
323
- void this.capture(shown?.index ?? 0, quality, {
324
- data: (chunk) => splitter.push(chunk, (unit, key) => {
325
- if (ws.readyState !== ws.OPEN)
440
+ let changing = false;
441
+ let lighter = false;
442
+ let askedForKey = 0;
443
+ // Nobody is looking at the viewer (its page is hidden): nothing is sent, and its slowness to answer means nothing.
444
+ let unwatched = false;
445
+ // Sending again starts at a keyframe.
446
+ let wantsKey = false;
447
+ let running;
448
+ // Dropping, with only a keyframe missing to carry on: a capture that makes one when asked is asked.
449
+ const askForKey = () => {
450
+ const now = Date.now();
451
+ if (!running?.key || now - askedForKey < 500 || !pacer.needsKey(now))
452
+ return;
453
+ askedForKey = now;
454
+ running.key();
455
+ };
456
+ // A still screen sends no frame to be reminded by.
457
+ const reminder = setInterval(askForKey, 500);
458
+ const events = {
459
+ frame: (unit, key) => {
460
+ if (ws.readyState !== ws.OPEN || unwatched)
326
461
  return;
327
- // Behind (a slow link): skip frames until the next keyframe rather than queue up delay.
328
- if (!key && ws.bufferedAmount > 2_000_000)
462
+ if (wantsKey && !key)
329
463
  return;
330
- ws.send(Buffer.concat([Buffer.from([key ? 1 : 0]), unit]));
331
- }),
464
+ wantsKey = false;
465
+ const now = Date.now();
466
+ const seq = pacer.next(key, now);
467
+ if (seq !== undefined) {
468
+ const head = Buffer.allocUnsafe(5);
469
+ head[0] = key ? 1 : 0;
470
+ head.writeUInt32BE(seq >>> 0, 1);
471
+ ws.send(Buffer.concat([head, unit]));
472
+ }
473
+ else
474
+ askForKey();
475
+ const advice = changing ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
476
+ if (advice)
477
+ void change(level + (advice === "down" ? 1 : -1));
478
+ },
479
+ // The page starts its decoder afresh for the new size; what it had of the old one came before this, in order.
480
+ resized: () => tell({ restart: true, level, lighter }),
332
481
  exit: (message) => {
333
482
  if (message)
334
483
  tell({ error: message });
335
484
  ws.close();
336
485
  },
337
- }).then((end) => {
486
+ };
487
+ const begin = async () => {
488
+ const started = await this.take(shown?.screen ?? 0, level, events, () => !closed);
338
489
  if (closed)
339
- end();
490
+ started?.end();
340
491
  else
341
- stop = end;
342
- }, (error) => {
492
+ running = started;
493
+ };
494
+ const failed = (error) => {
343
495
  this.log(`[screen] the capture did not start: ${error.message}`);
344
496
  tell({ error: "屏幕捕获失败" });
345
497
  ws.close();
498
+ };
499
+ const change = async (next) => {
500
+ changing = true;
501
+ lighter = next > level;
502
+ const to = rung(next);
503
+ this.log(`[screen] picture ${lighter ? "lighter" : "better"}: ${to.width} wide, ${to.fps} a second, ${Math.round(to.bitrate / 1000)} kbit/s`);
504
+ if (running?.change) {
505
+ // The capture carries on: the new level takes over at a keyframe, with nothing lost in between.
506
+ running.change(next);
507
+ level = next;
508
+ pacer.changed(Date.now());
509
+ changing = false;
510
+ return;
511
+ }
512
+ // Nothing more of the old stream goes out; the page starts its decoder afresh for the new one.
513
+ running?.end();
514
+ tell({ restart: true, level: next, lighter });
515
+ level = next;
516
+ await begin().catch(failed);
517
+ pacer.restarted(Date.now());
518
+ changing = false;
519
+ };
520
+ // The picture as a video track; down this socket when that can't be had, or didn't get across.
521
+ let video;
522
+ const endVideo = () => {
523
+ if (!video)
524
+ return;
525
+ video.app.endVideo(video.id);
526
+ if (this.video?.id === video.id)
527
+ this.video = undefined;
528
+ video = undefined;
529
+ };
530
+ const fallBack = () => {
531
+ if (!video)
532
+ return;
533
+ endVideo();
534
+ if (closed)
535
+ return;
536
+ tell({ rtc: { t: "off" } });
537
+ void begin().catch(failed);
538
+ };
539
+ if (wantsVideo) {
540
+ this.offerVideo(shown?.screen ?? 0, {
541
+ closed: () => closed,
542
+ tell,
543
+ refuse: (message) => {
544
+ video = undefined;
545
+ tell({ error: message });
546
+ ws.close();
547
+ },
548
+ lost: fallBack,
549
+ })
550
+ .then((offered) => {
551
+ if (offered === "refused")
552
+ return;
553
+ if (closed) {
554
+ if (offered)
555
+ offered.app.endVideo(offered.id);
556
+ return;
557
+ }
558
+ if (offered)
559
+ video = offered;
560
+ else {
561
+ tell({ rtc: { t: "off" } });
562
+ return begin();
563
+ }
564
+ })
565
+ .catch(failed);
566
+ }
567
+ else
568
+ void begin().catch(failed);
569
+ ws.on("message", (data, binary) => {
570
+ if (binary)
571
+ return;
572
+ let message;
573
+ try {
574
+ message = JSON.parse(String(data));
575
+ }
576
+ catch {
577
+ return;
578
+ }
579
+ const kind = message?.t;
580
+ if (kind === "ack")
581
+ pacer.ack(Number(message.n), Date.now());
582
+ else if (kind === "rtc.answer" || kind === "rtc.ice") {
583
+ const signal = videoSignal.safeParse(message);
584
+ if (signal.success && video)
585
+ video.app.signal(video.id, signal.data);
586
+ }
587
+ // The viewer's decoder lost its place (it skipped ahead, or failed): it can only carry on from a keyframe.
588
+ // (Named apart from the viewer's own events: whatever isn't one of these kinds is taken for a pointer or key event.)
589
+ else if (kind === "keyframe") {
590
+ const now = Date.now();
591
+ if (running?.key && now - askedForKey >= 500) {
592
+ askedForKey = now;
593
+ running.key();
594
+ }
595
+ }
596
+ else if (kind === "hidden")
597
+ unwatched = true;
598
+ else if (kind === "shown") {
599
+ if (!unwatched)
600
+ return;
601
+ unwatched = false;
602
+ wantsKey = true;
603
+ // What was on its way when the page went out of sight is not waited for.
604
+ pacer.restarted(Date.now());
605
+ running?.key?.();
606
+ }
607
+ // No direct path between the two, or none in time: the viewer asks for the picture here instead.
608
+ else if (kind === "rtc.failed") {
609
+ this.log(`[screen] no video track for this viewer (${String(message.reason ?? "no reason given").slice(0, 200)}): the picture goes down the socket`);
610
+ fallBack();
611
+ }
612
+ // A viewer measuring (`?measure=1`): its clock against this computer's (it keeps the answer that came back
613
+ // quickest), then what it sees, once a second.
614
+ else if (kind === "ping")
615
+ tell({ pong: { n: message.n, at: message.at, now: Date.now() } });
616
+ else if (kind === "measure")
617
+ this.log(`[screen] measured: ${JSON.stringify(message).slice(0, 1000)}`);
618
+ else if (kind === "control")
619
+ void control.start(shown?.screen ?? 0);
620
+ else
621
+ control.send(message);
346
622
  });
347
623
  ws.on("close", () => {
348
624
  closed = true;
349
- stop?.();
625
+ clearInterval(reminder);
626
+ if (running && this.holder === running)
627
+ this.holder = undefined;
628
+ running?.end();
629
+ endVideo();
350
630
  control.stop();
351
631
  this.controls.delete(control);
352
632
  });
353
633
  }
354
634
  stop() {
355
635
  // The host is going: there is no later to insist in.
636
+ this.holder?.end();
637
+ this.holder = undefined;
638
+ this.video = undefined;
356
639
  for (const capture of this.captures)
357
640
  endCapture(capture, true);
358
641
  this.captures.clear();
359
- for (const stop of this.stops)
360
- stop();
361
- this.stops.clear();
362
642
  for (const control of this.controls)
363
643
  control.stop();
364
644
  this.controls.clear();