linkshell-cli 0.8.2 → 0.10.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/README.md +11 -6
- package/dist/cli/src/commands/gateway.d.ts +2 -0
- package/dist/cli/src/commands/gateway.js +29 -0
- package/dist/cli/src/commands/gateway.js.map +1 -0
- package/dist/cli/src/commands/host.d.ts +4 -0
- package/dist/cli/src/commands/host.js +12 -2
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/screen.d.ts +10 -0
- package/dist/cli/src/commands/screen.js +82 -74
- package/dist/cli/src/commands/screen.js.map +1 -1
- package/dist/cli/src/commands/setup.d.ts +11 -1
- package/dist/cli/src/commands/setup.js +102 -38
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/index.js +28 -338
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/utils/daemon.d.ts +0 -7
- package/dist/cli/src/utils/daemon.js +1 -18
- package/dist/cli/src/utils/daemon.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/gateway/src/accounts.d.ts +6 -0
- package/dist/gateway/src/accounts.js +32 -0
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -0
- package/dist/gateway/src/index.js +5 -0
- package/dist/gateway/src/index.js.map +1 -0
- package/dist/gateway/src/main.d.ts +1 -0
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -0
- package/dist/gateway/src/relay.js +434 -0
- package/dist/gateway/src/relay.js.map +1 -0
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/gateway/src/store.d.ts +35 -0
- package/dist/gateway/src/store.js +109 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
- package/dist/host/src/fs.d.ts +3 -3
- 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 +25 -11
- package/dist/host/src/screen-pacer.js +28 -6
- package/dist/host/src/screen-pacer.js.map +1 -1
- package/dist/host/src/screen-viewer.d.ts +5 -0
- package/dist/host/src/screen-viewer.js +1231 -55
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +19 -10
- package/dist/host/src/screen.js +314 -108
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/terminals.d.ts +1 -1
- package/dist/host/src/terminals.js +1 -0
- package/dist/host/src/terminals.js.map +1 -1
- package/dist/wire/src/model.d.ts +6 -6
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +818 -818
- package/dist/wire/src/updates.d.ts +18 -18
- package/package.json +6 -15
- package/src/commands/gateway.ts +31 -0
- package/src/commands/host.ts +15 -2
- package/src/commands/screen.ts +78 -72
- package/src/commands/setup.ts +102 -60
- package/src/index.ts +29 -433
- package/src/utils/daemon.ts +2 -27
- package/dist/cli/src/config.d.ts +0 -14
- package/dist/cli/src/config.js +0 -23
- package/dist/cli/src/config.js.map +0 -1
- package/dist/cli/src/machine-id.d.ts +0 -6
- package/dist/cli/src/machine-id.js +0 -50
- package/dist/cli/src/machine-id.js.map +0 -1
- package/dist/cli/src/providers.d.ts +0 -13
- package/dist/cli/src/providers.js +0 -77
- package/dist/cli/src/providers.js.map +0 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
- package/dist/cli/src/runtime/acp/acp-client.js +0 -430
- package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
- package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
- package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
- package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
- package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
- package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
- package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
- package/dist/cli/src/runtime/bridge-session.js +0 -1364
- package/dist/cli/src/runtime/bridge-session.js.map +0 -1
- package/dist/cli/src/runtime/hook-command.d.ts +0 -9
- package/dist/cli/src/runtime/hook-command.js +0 -80
- package/dist/cli/src/runtime/hook-command.js.map +0 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
- package/dist/cli/src/runtime/screen-fallback.js +0 -202
- package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
- package/dist/cli/src/runtime/screen-share.d.ts +0 -34
- package/dist/cli/src/runtime/screen-share.js +0 -376
- package/dist/cli/src/runtime/screen-share.js.map +0 -1
- package/dist/cli/src/runtime/scrollback.d.ts +0 -11
- package/dist/cli/src/runtime/scrollback.js +0 -33
- package/dist/cli/src/runtime/scrollback.js.map +0 -1
- package/dist/cli/src/runtime/usage-report.d.ts +0 -34
- package/dist/cli/src/runtime/usage-report.js +0 -332
- package/dist/cli/src/runtime/usage-report.js.map +0 -1
- package/dist/cli/src/utils/keep-awake.d.ts +0 -6
- package/dist/cli/src/utils/keep-awake.js +0 -48
- package/dist/cli/src/utils/keep-awake.js.map +0 -1
- package/dist/cli/src/utils/lan-ip.d.ts +0 -9
- package/dist/cli/src/utils/lan-ip.js +0 -61
- package/dist/cli/src/utils/lan-ip.js.map +0 -1
- package/dist/host/src/input-helper.d.ts +0 -1
- package/dist/host/src/input-helper.js +0 -564
- package/dist/host/src/input-helper.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/scripts/copy-web.mjs +0 -34
- package/src/config.ts +0 -36
- package/src/machine-id.ts +0 -61
- package/src/providers.ts +0 -97
- package/src/runtime/acp/acp-client.ts +0 -521
- package/src/runtime/acp/agent-process-discovery.ts +0 -308
- package/src/runtime/acp/agent-session.ts +0 -1186
- package/src/runtime/acp/agent-workspace.ts +0 -6590
- package/src/runtime/acp/claude-sdk-client.ts +0 -1221
- package/src/runtime/acp/claude-stream-json-client.ts +0 -633
- package/src/runtime/acp/json-rpc.ts +0 -184
- package/src/runtime/acp/provider-resolver.ts +0 -154
- package/src/runtime/bridge-session.ts +0 -1549
- package/src/runtime/hook-command.ts +0 -73
- package/src/runtime/screen-fallback.ts +0 -235
- package/src/runtime/screen-share.ts +0 -426
- package/src/runtime/scrollback.ts +0 -43
- package/src/runtime/usage-report.ts +0 -342
- package/src/utils/keep-awake.ts +0 -61
- package/src/utils/lan-ip.ts +0 -61
- package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
- package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
- package/web/assets/TerminalPanel-CTSklkct.js +0 -1
- package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
- package/web/assets/index-BsnDaeCt.css +0 -33
- package/web/assets/index-q85psYZ5.js +0 -36
- package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
- package/web/assets/vendor-react-M7xG3qiw.js +0 -49
- package/web/assets/vendor-xterm-D15THcI4.js +0 -9
- package/web/index.html +0 -37
- package/web/logos/claude.png +0 -0
- package/web/logos/codex.png +0 -0
- package/web/logos/copilot.png +0 -0
- package/web/logos/gemini.png +0 -0
- package/web/logos/linkshell-1024.png +0 -0
- package/web/logos/linkshell-splash.png +0 -0
- package/web/logos/linkshell.png +0 -0
package/dist/host/src/screen.js
CHANGED
|
@@ -4,63 +4,43 @@ 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,
|
|
8
|
-
import { LADDER, Pacer, RELAY_LEVEL } from "./screen-pacer.js";
|
|
7
|
+
import { closeInputApp, inputApp, InputControl, NO_APP, videoSignal } from "./input.js";
|
|
8
|
+
import { LADDER, Pacer, RELAY_LEVEL, rung } from "./screen-pacer.js";
|
|
9
9
|
import { viewerPage } from "./screen-viewer.js";
|
|
10
10
|
// Viewing the computer's screen from the phone. The host serves a small
|
|
11
|
-
// viewer page
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// without having Screen Recording permission themselves. The same socket
|
|
16
|
-
// 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.
|
|
17
15
|
//
|
|
18
|
-
// On a Mac the
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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.
|
|
21
25
|
const run = promisify(execFile);
|
|
22
26
|
async function hasFfmpeg() {
|
|
23
27
|
return run("ffmpeg", ["-hide_banner", "-version"], { timeout: 5000 }).then(() => true, () => false);
|
|
24
28
|
}
|
|
25
|
-
/** Where ffmpeg is: an app the system opened has no shell's PATH to find it on. */
|
|
26
|
-
async function ffmpegPath() {
|
|
27
|
-
return run("/usr/bin/which", ["ffmpeg"], { timeout: 5000 }).then(({ stdout }) => stdout.trim() || "ffmpeg", () => "ffmpeg");
|
|
28
|
-
}
|
|
29
29
|
/** What the phone is told when the picture can't be had for want of the permission. */
|
|
30
30
|
const notAllowed = (app) => `电脑还没有允许${app ? `「${app}」` : ""}录制屏幕。在电脑上运行 linkshell screen 按提示操作,或在「系统设置 › 隐私与安全性 › 录屏与系统录音」里打开${app ? `「${app}」` : "它"}的开关,然后重新进入这个页面。`;
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const output = await run("ffmpeg", ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], { timeout: 8000 }).then(({ stderr }) => stderr, (error) => error.stderr ?? "");
|
|
36
|
-
const displays = [];
|
|
37
|
-
for (const match of output.matchAll(/\[(\d+)\] Capture screen (\d+)/g)) {
|
|
38
|
-
displays.push({ index: Number(match[1]), name: `屏幕 ${Number(match[2]) + 1}`, screen: Number(match[2]) });
|
|
39
|
-
}
|
|
40
|
-
return displays;
|
|
41
|
-
}
|
|
42
|
-
/** The capture for one rung of the ladder (see `screen-pacer.ts`): 0 is the best picture. */
|
|
43
|
-
export function captureArgs(display, level = 0) {
|
|
44
|
-
const profile = LADDER[Math.min(Math.max(level, 0), LADDER.length - 1)];
|
|
45
|
-
const input = process.platform === "darwin"
|
|
46
|
-
? ["-f", "avfoundation", "-capture_cursor", "1", "-framerate", String(profile.fps), "-i", `${display}:none`]
|
|
47
|
-
: ["-f", "x11grab", "-framerate", String(profile.fps), "-i", process.env.DISPLAY ?? ":0"];
|
|
48
|
-
const encode = process.platform === "darwin"
|
|
49
|
-
? ["-c:v", "h264_videotoolbox", "-realtime", "1", "-profile:v", "baseline", "-b:v", profile.bitrate, "-maxrate", profile.ceiling]
|
|
50
|
-
: [
|
|
51
|
-
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", "-x264-params", "sliced-threads=0",
|
|
52
|
-
"-b:v", profile.bitrate, "-maxrate", profile.ceiling, "-bufsize", profile.ceiling,
|
|
53
|
-
];
|
|
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`;
|
|
54
35
|
return [
|
|
55
36
|
"-hide_banner",
|
|
56
37
|
"-loglevel",
|
|
57
38
|
"error",
|
|
58
|
-
|
|
39
|
+
"-f", "x11grab", "-framerate", String(profile.fps), "-i", process.env.DISPLAY ?? ":0",
|
|
59
40
|
"-vf",
|
|
60
|
-
// AVFoundation captures a screen at its refresh rate whatever -framerate asks (120 a second on a
|
|
61
|
-
// ProMotion display): the frames are dropped here, before the encoder and the network pay for them.
|
|
62
41
|
`fps=${profile.fps},scale='min(${profile.width},iw)':-2,format=yuv420p`,
|
|
63
|
-
|
|
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),
|
|
64
44
|
// A keyframe a second: a viewer that fell behind is back on the live picture within one.
|
|
65
45
|
"-g",
|
|
66
46
|
String(profile.fps),
|
|
@@ -110,10 +90,7 @@ export class AccessUnitSplitter {
|
|
|
110
90
|
return false;
|
|
111
91
|
}
|
|
112
92
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Ends a capture. ffmpeg holding a screen through avfoundation can sit through
|
|
115
|
-
* SIGTERM — and through its output going away — so it is asked, then made to.
|
|
116
|
-
*/
|
|
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. */
|
|
117
94
|
export function endCapture(capture, now = false) {
|
|
118
95
|
if (capture.exitCode !== null || capture.signalCode !== null)
|
|
119
96
|
return;
|
|
@@ -163,6 +140,7 @@ export async function reapOrphanCaptures(log) {
|
|
|
163
140
|
/** The loopback viewer server; started on first use, one capture per watching device. */
|
|
164
141
|
export class ScreenShare {
|
|
165
142
|
log;
|
|
143
|
+
iceServers;
|
|
166
144
|
server;
|
|
167
145
|
token = "";
|
|
168
146
|
displays = [];
|
|
@@ -174,17 +152,18 @@ export class ScreenShare {
|
|
|
174
152
|
*/
|
|
175
153
|
holder;
|
|
176
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 });
|
|
@@ -212,7 +211,9 @@ export class ScreenShare {
|
|
|
212
211
|
}
|
|
213
212
|
// "low": the viewer is reached through a gateway, which should carry messages rather than video.
|
|
214
213
|
const relayed = url.searchParams.get("q") === "low";
|
|
215
|
-
|
|
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));
|
|
216
217
|
});
|
|
217
218
|
this.server = server;
|
|
218
219
|
return new Promise((resolve, reject) => {
|
|
@@ -226,79 +227,97 @@ export class ScreenShare {
|
|
|
226
227
|
return this.token.length > 0 && given.length === expected.length && timingSafeEqual(given, expected);
|
|
227
228
|
}
|
|
228
229
|
/**
|
|
229
|
-
* Whether the screen can be watched and controlled
|
|
230
|
-
*
|
|
231
|
-
* `
|
|
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.)
|
|
232
233
|
*/
|
|
233
234
|
async access(ask) {
|
|
234
235
|
const supported = process.platform === "darwin" || process.platform === "linux";
|
|
235
|
-
const ffmpeg = supported && (await hasFfmpeg());
|
|
236
236
|
if (process.platform !== "darwin")
|
|
237
|
-
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" };
|
|
238
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
|
+
}
|
|
239
245
|
try {
|
|
240
|
-
const status =
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
await (app ? app.ask(missing) : hostAccess(this.log, missing));
|
|
245
|
-
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 };
|
|
246
250
|
}
|
|
247
251
|
catch (error) {
|
|
248
|
-
return { supported, ffmpeg, recording: null, control: null, problem: error.message };
|
|
252
|
+
return { supported, ffmpeg: true, recording: null, control: null, problem: error.message };
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
/** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
|
|
252
|
-
async capture(
|
|
253
|
-
const args = captureArgs(index, level);
|
|
256
|
+
async capture(screen, level, on) {
|
|
254
257
|
let ended = false;
|
|
255
258
|
let leave = () => { };
|
|
256
259
|
const gone = new Promise((resolve) => (leave = resolve));
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (ended)
|
|
264
|
-
return;
|
|
265
|
-
if (code)
|
|
266
|
-
this.log(`[screen] capture exited ${code}: ${errors.trim()}`);
|
|
267
|
-
on.exit(!code ? undefined : /permission|not authorized|denied/i.test(errors) ? notAllowed("") : "屏幕捕获失败");
|
|
268
|
-
};
|
|
269
|
-
const app = process.platform === "darwin" ? inputApp(this.log) : undefined;
|
|
270
|
-
if (app) {
|
|
260
|
+
if (process.platform === "darwin") {
|
|
261
|
+
const app = inputApp(this.log);
|
|
262
|
+
if (!app) {
|
|
263
|
+
on.exit(NO_APP);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
271
266
|
const status = await app.access();
|
|
272
267
|
if (!status.recording) {
|
|
273
|
-
// Someone may be at the computer: the
|
|
274
|
-
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(() => { });
|
|
275
270
|
on.exit(notAllowed(status.app));
|
|
276
271
|
return undefined;
|
|
277
272
|
}
|
|
278
|
-
|
|
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
|
+
});
|
|
279
292
|
return {
|
|
280
293
|
on,
|
|
281
294
|
gone,
|
|
282
295
|
end: () => {
|
|
283
296
|
ended = true;
|
|
284
|
-
|
|
297
|
+
stream.end();
|
|
298
|
+
leave();
|
|
285
299
|
},
|
|
300
|
+
change: (next) => stream.set(rung(next)),
|
|
301
|
+
key: () => stream.key(),
|
|
286
302
|
};
|
|
287
303
|
}
|
|
288
|
-
|
|
289
|
-
// Without the permission the capture just waits, and so would the phone.
|
|
290
|
-
const status = await hostAccess(this.log).catch(() => undefined);
|
|
291
|
-
if (status && !status.recording) {
|
|
292
|
-
void hostAccess(this.log, "recording").catch(() => { });
|
|
293
|
-
on.exit(notAllowed(status.app));
|
|
294
|
-
return undefined;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
const capture = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
304
|
+
const capture = spawn("ffmpeg", captureArgs(level), { stdio: ["ignore", "pipe", "pipe"] });
|
|
298
305
|
this.captures.add(capture);
|
|
306
|
+
const splitter = new AccessUnitSplitter();
|
|
299
307
|
let errors = "";
|
|
300
|
-
capture.stdout.on("data",
|
|
308
|
+
capture.stdout.on("data", (chunk) => {
|
|
309
|
+
if (!ended)
|
|
310
|
+
splitter.push(chunk, on.frame);
|
|
311
|
+
});
|
|
301
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
|
+
};
|
|
302
321
|
capture.on("error", (error) => finished(-1, error.message));
|
|
303
322
|
capture.on("exit", (code) => {
|
|
304
323
|
this.captures.delete(capture);
|
|
@@ -314,8 +333,9 @@ export class ScreenShare {
|
|
|
314
333
|
};
|
|
315
334
|
}
|
|
316
335
|
/** Takes the screen for a new capture: whatever holds it is ended, and has left, before the new one starts. */
|
|
317
|
-
take(
|
|
336
|
+
take(screen, level, on, wanted) {
|
|
318
337
|
const mine = this.turn.then(async () => {
|
|
338
|
+
this.video?.taken();
|
|
319
339
|
const previous = this.holder;
|
|
320
340
|
if (previous) {
|
|
321
341
|
this.holder = undefined;
|
|
@@ -327,14 +347,82 @@ export class ScreenShare {
|
|
|
327
347
|
}
|
|
328
348
|
if (!wanted())
|
|
329
349
|
return undefined;
|
|
330
|
-
const running = await this.capture(
|
|
350
|
+
const running = await this.capture(screen, level, on);
|
|
331
351
|
this.holder = running;
|
|
332
352
|
return running;
|
|
333
353
|
});
|
|
334
354
|
this.turn = mine.catch(() => { });
|
|
335
355
|
return mine;
|
|
336
356
|
}
|
|
337
|
-
|
|
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 };
|
|
424
|
+
}
|
|
425
|
+
stream(ws, display, relayed, wantsVideo) {
|
|
338
426
|
const shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
|
|
339
427
|
const tell = (message) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(message));
|
|
340
428
|
// The viewer's hands: started when it first asks to control, for the display it is watching.
|
|
@@ -348,14 +436,32 @@ export class ScreenShare {
|
|
|
348
436
|
const best = relayed ? RELAY_LEVEL : 0;
|
|
349
437
|
const pacer = new Pacer(Date.now());
|
|
350
438
|
let level = best;
|
|
351
|
-
let splitter = new AccessUnitSplitter();
|
|
352
439
|
let closed = false;
|
|
353
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;
|
|
354
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);
|
|
355
458
|
const events = {
|
|
356
|
-
|
|
357
|
-
if (ws.readyState !== ws.OPEN)
|
|
459
|
+
frame: (unit, key) => {
|
|
460
|
+
if (ws.readyState !== ws.OPEN || unwatched)
|
|
461
|
+
return;
|
|
462
|
+
if (wantsKey && !key)
|
|
358
463
|
return;
|
|
464
|
+
wantsKey = false;
|
|
359
465
|
const now = Date.now();
|
|
360
466
|
const seq = pacer.next(key, now);
|
|
361
467
|
if (seq !== undefined) {
|
|
@@ -364,10 +470,14 @@ export class ScreenShare {
|
|
|
364
470
|
head.writeUInt32BE(seq >>> 0, 1);
|
|
365
471
|
ws.send(Buffer.concat([head, unit]));
|
|
366
472
|
}
|
|
473
|
+
else
|
|
474
|
+
askForKey();
|
|
367
475
|
const advice = changing ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
|
|
368
476
|
if (advice)
|
|
369
477
|
void change(level + (advice === "down" ? 1 : -1));
|
|
370
|
-
}
|
|
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 }),
|
|
371
481
|
exit: (message) => {
|
|
372
482
|
if (message)
|
|
373
483
|
tell({ error: message });
|
|
@@ -375,7 +485,7 @@ export class ScreenShare {
|
|
|
375
485
|
},
|
|
376
486
|
};
|
|
377
487
|
const begin = async () => {
|
|
378
|
-
const started = await this.take(shown?.
|
|
488
|
+
const started = await this.take(shown?.screen ?? 0, level, events, () => !closed);
|
|
379
489
|
if (closed)
|
|
380
490
|
started?.end();
|
|
381
491
|
else
|
|
@@ -388,17 +498,74 @@ export class ScreenShare {
|
|
|
388
498
|
};
|
|
389
499
|
const change = async (next) => {
|
|
390
500
|
changing = true;
|
|
391
|
-
|
|
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
|
+
}
|
|
392
512
|
// Nothing more of the old stream goes out; the page starts its decoder afresh for the new one.
|
|
393
513
|
running?.end();
|
|
394
|
-
tell({ restart: true, level: next, lighter
|
|
514
|
+
tell({ restart: true, level: next, lighter });
|
|
395
515
|
level = next;
|
|
396
|
-
splitter = new AccessUnitSplitter();
|
|
397
516
|
await begin().catch(failed);
|
|
398
517
|
pacer.restarted(Date.now());
|
|
399
518
|
changing = false;
|
|
400
519
|
};
|
|
401
|
-
|
|
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);
|
|
402
569
|
ws.on("message", (data, binary) => {
|
|
403
570
|
if (binary)
|
|
404
571
|
return;
|
|
@@ -412,6 +579,42 @@ export class ScreenShare {
|
|
|
412
579
|
const kind = message?.t;
|
|
413
580
|
if (kind === "ack")
|
|
414
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)}`);
|
|
415
618
|
else if (kind === "control")
|
|
416
619
|
void control.start(shown?.screen ?? 0);
|
|
417
620
|
else
|
|
@@ -419,9 +622,11 @@ export class ScreenShare {
|
|
|
419
622
|
});
|
|
420
623
|
ws.on("close", () => {
|
|
421
624
|
closed = true;
|
|
625
|
+
clearInterval(reminder);
|
|
422
626
|
if (running && this.holder === running)
|
|
423
627
|
this.holder = undefined;
|
|
424
628
|
running?.end();
|
|
629
|
+
endVideo();
|
|
425
630
|
control.stop();
|
|
426
631
|
this.controls.delete(control);
|
|
427
632
|
});
|
|
@@ -430,6 +635,7 @@ export class ScreenShare {
|
|
|
430
635
|
// The host is going: there is no later to insist in.
|
|
431
636
|
this.holder?.end();
|
|
432
637
|
this.holder = undefined;
|
|
638
|
+
this.video = undefined;
|
|
433
639
|
for (const capture of this.captures)
|
|
434
640
|
endCapture(capture, true);
|
|
435
641
|
this.captures.clear();
|