@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.
- package/dist/host/src/input.d.ts +112 -27
- package/dist/host/src/input.js +214 -144
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/rpc/server.js +1 -1
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen-pacer.d.ts +76 -0
- package/dist/host/src/screen-pacer.js +165 -0
- package/dist/host/src/screen-pacer.js.map +1 -0
- package/dist/host/src/screen-viewer.d.ts +5 -0
- package/dist/host/src/screen-viewer.js +1263 -57
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +29 -32
- package/dist/host/src/screen.js +418 -138
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/terminals.d.ts +3 -3
- package/dist/host/src/terminals.js +1 -0
- package/dist/host/src/terminals.js.map +1 -1
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/helper/LinkShell.app/Contents/Info.plist +0 -19
- package/helper/LinkShell.app/Contents/MacOS/LinkShell +0 -0
- package/helper/LinkShell.app/Contents/Resources/AppIcon.icns +0 -0
- package/helper/LinkShell.app/Contents/_CodeSignature/CodeResources +0 -128
package/dist/host/src/screen.js
CHANGED
|
@@ -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,
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
229
|
-
*
|
|
230
|
-
* `
|
|
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 =
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
251
|
-
async capture(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
263
|
-
void app.ask(
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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,
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
if (!key && ws.bufferedAmount > 2_000_000)
|
|
462
|
+
if (wantsKey && !key)
|
|
329
463
|
return;
|
|
330
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
342
|
-
}
|
|
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
|
-
|
|
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();
|