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.
Files changed (173) hide show
  1. package/README.md +11 -6
  2. package/dist/cli/src/commands/gateway.d.ts +2 -0
  3. package/dist/cli/src/commands/gateway.js +29 -0
  4. package/dist/cli/src/commands/gateway.js.map +1 -0
  5. package/dist/cli/src/commands/host.d.ts +4 -0
  6. package/dist/cli/src/commands/host.js +12 -2
  7. package/dist/cli/src/commands/host.js.map +1 -1
  8. package/dist/cli/src/commands/screen.d.ts +10 -0
  9. package/dist/cli/src/commands/screen.js +82 -74
  10. package/dist/cli/src/commands/screen.js.map +1 -1
  11. package/dist/cli/src/commands/setup.d.ts +11 -1
  12. package/dist/cli/src/commands/setup.js +102 -38
  13. package/dist/cli/src/commands/setup.js.map +1 -1
  14. package/dist/cli/src/index.js +28 -338
  15. package/dist/cli/src/index.js.map +1 -1
  16. package/dist/cli/src/utils/daemon.d.ts +0 -7
  17. package/dist/cli/src/utils/daemon.js +1 -18
  18. package/dist/cli/src/utils/daemon.js.map +1 -1
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/gateway/src/accounts.d.ts +6 -0
  21. package/dist/gateway/src/accounts.js +32 -0
  22. package/dist/gateway/src/accounts.js.map +1 -0
  23. package/dist/gateway/src/index.d.ts +4 -0
  24. package/dist/gateway/src/index.js +5 -0
  25. package/dist/gateway/src/index.js.map +1 -0
  26. package/dist/gateway/src/main.d.ts +1 -0
  27. package/dist/gateway/src/main.js +57 -0
  28. package/dist/gateway/src/main.js.map +1 -0
  29. package/dist/gateway/src/rate-limit.d.ts +22 -0
  30. package/dist/gateway/src/rate-limit.js +66 -0
  31. package/dist/gateway/src/rate-limit.js.map +1 -0
  32. package/dist/gateway/src/relay.d.ts +84 -0
  33. package/dist/gateway/src/relay.js +434 -0
  34. package/dist/gateway/src/relay.js.map +1 -0
  35. package/dist/gateway/src/serve.d.ts +34 -0
  36. package/dist/gateway/src/serve.js +102 -0
  37. package/dist/gateway/src/serve.js.map +1 -0
  38. package/dist/gateway/src/store.d.ts +35 -0
  39. package/dist/gateway/src/store.js +109 -0
  40. package/dist/gateway/src/store.js.map +1 -0
  41. package/dist/gateway/src/subscription.d.ts +18 -0
  42. package/dist/gateway/src/subscription.js +46 -0
  43. package/dist/gateway/src/subscription.js.map +1 -0
  44. package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
  45. package/dist/host/src/fs.d.ts +3 -3
  46. package/dist/host/src/input.d.ts +112 -27
  47. package/dist/host/src/input.js +214 -144
  48. package/dist/host/src/input.js.map +1 -1
  49. package/dist/host/src/rpc/server.js +1 -1
  50. package/dist/host/src/rpc/server.js.map +1 -1
  51. package/dist/host/src/screen-pacer.d.ts +25 -11
  52. package/dist/host/src/screen-pacer.js +28 -6
  53. package/dist/host/src/screen-pacer.js.map +1 -1
  54. package/dist/host/src/screen-viewer.d.ts +5 -0
  55. package/dist/host/src/screen-viewer.js +1231 -55
  56. package/dist/host/src/screen-viewer.js.map +1 -1
  57. package/dist/host/src/screen.d.ts +19 -10
  58. package/dist/host/src/screen.js +314 -108
  59. package/dist/host/src/screen.js.map +1 -1
  60. package/dist/host/src/terminals.d.ts +1 -1
  61. package/dist/host/src/terminals.js +1 -0
  62. package/dist/host/src/terminals.js.map +1 -1
  63. package/dist/wire/src/model.d.ts +6 -6
  64. package/dist/wire/src/relay.d.ts +8 -8
  65. package/dist/wire/src/rpc.d.ts +818 -818
  66. package/dist/wire/src/updates.d.ts +18 -18
  67. package/package.json +6 -15
  68. package/src/commands/gateway.ts +31 -0
  69. package/src/commands/host.ts +15 -2
  70. package/src/commands/screen.ts +78 -72
  71. package/src/commands/setup.ts +102 -60
  72. package/src/index.ts +29 -433
  73. package/src/utils/daemon.ts +2 -27
  74. package/dist/cli/src/config.d.ts +0 -14
  75. package/dist/cli/src/config.js +0 -23
  76. package/dist/cli/src/config.js.map +0 -1
  77. package/dist/cli/src/machine-id.d.ts +0 -6
  78. package/dist/cli/src/machine-id.js +0 -50
  79. package/dist/cli/src/machine-id.js.map +0 -1
  80. package/dist/cli/src/providers.d.ts +0 -13
  81. package/dist/cli/src/providers.js +0 -77
  82. package/dist/cli/src/providers.js.map +0 -1
  83. package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
  84. package/dist/cli/src/runtime/acp/acp-client.js +0 -430
  85. package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
  86. package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
  87. package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
  88. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
  89. package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
  90. package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
  91. package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
  92. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
  93. package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
  94. package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
  95. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
  96. package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
  97. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
  98. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
  99. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
  100. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
  101. package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
  102. package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
  103. package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
  104. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
  105. package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
  106. package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
  107. package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
  108. package/dist/cli/src/runtime/bridge-session.js +0 -1364
  109. package/dist/cli/src/runtime/bridge-session.js.map +0 -1
  110. package/dist/cli/src/runtime/hook-command.d.ts +0 -9
  111. package/dist/cli/src/runtime/hook-command.js +0 -80
  112. package/dist/cli/src/runtime/hook-command.js.map +0 -1
  113. package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
  114. package/dist/cli/src/runtime/screen-fallback.js +0 -202
  115. package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
  116. package/dist/cli/src/runtime/screen-share.d.ts +0 -34
  117. package/dist/cli/src/runtime/screen-share.js +0 -376
  118. package/dist/cli/src/runtime/screen-share.js.map +0 -1
  119. package/dist/cli/src/runtime/scrollback.d.ts +0 -11
  120. package/dist/cli/src/runtime/scrollback.js +0 -33
  121. package/dist/cli/src/runtime/scrollback.js.map +0 -1
  122. package/dist/cli/src/runtime/usage-report.d.ts +0 -34
  123. package/dist/cli/src/runtime/usage-report.js +0 -332
  124. package/dist/cli/src/runtime/usage-report.js.map +0 -1
  125. package/dist/cli/src/utils/keep-awake.d.ts +0 -6
  126. package/dist/cli/src/utils/keep-awake.js +0 -48
  127. package/dist/cli/src/utils/keep-awake.js.map +0 -1
  128. package/dist/cli/src/utils/lan-ip.d.ts +0 -9
  129. package/dist/cli/src/utils/lan-ip.js +0 -61
  130. package/dist/cli/src/utils/lan-ip.js.map +0 -1
  131. package/dist/host/src/input-helper.d.ts +0 -1
  132. package/dist/host/src/input-helper.js +0 -564
  133. package/dist/host/src/input-helper.js.map +0 -1
  134. package/dist/shared-protocol/src/index.d.ts +0 -17029
  135. package/dist/shared-protocol/src/index.js +0 -1064
  136. package/dist/shared-protocol/src/index.js.map +0 -1
  137. package/scripts/copy-web.mjs +0 -34
  138. package/src/config.ts +0 -36
  139. package/src/machine-id.ts +0 -61
  140. package/src/providers.ts +0 -97
  141. package/src/runtime/acp/acp-client.ts +0 -521
  142. package/src/runtime/acp/agent-process-discovery.ts +0 -308
  143. package/src/runtime/acp/agent-session.ts +0 -1186
  144. package/src/runtime/acp/agent-workspace.ts +0 -6590
  145. package/src/runtime/acp/claude-sdk-client.ts +0 -1221
  146. package/src/runtime/acp/claude-stream-json-client.ts +0 -633
  147. package/src/runtime/acp/json-rpc.ts +0 -184
  148. package/src/runtime/acp/provider-resolver.ts +0 -154
  149. package/src/runtime/bridge-session.ts +0 -1549
  150. package/src/runtime/hook-command.ts +0 -73
  151. package/src/runtime/screen-fallback.ts +0 -235
  152. package/src/runtime/screen-share.ts +0 -426
  153. package/src/runtime/scrollback.ts +0 -43
  154. package/src/runtime/usage-report.ts +0 -342
  155. package/src/utils/keep-awake.ts +0 -61
  156. package/src/utils/lan-ip.ts +0 -61
  157. package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
  158. package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
  159. package/web/assets/TerminalPanel-CTSklkct.js +0 -1
  160. package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
  161. package/web/assets/index-BsnDaeCt.css +0 -33
  162. package/web/assets/index-q85psYZ5.js +0 -36
  163. package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
  164. package/web/assets/vendor-react-M7xG3qiw.js +0 -49
  165. package/web/assets/vendor-xterm-D15THcI4.js +0 -9
  166. package/web/index.html +0 -37
  167. package/web/logos/claude.png +0 -0
  168. package/web/logos/codex.png +0 -0
  169. package/web/logos/copilot.png +0 -0
  170. package/web/logos/gemini.png +0 -0
  171. package/web/logos/linkshell-1024.png +0 -0
  172. package/web/logos/linkshell-splash.png +0 -0
  173. package/web/logos/linkshell.png +0 -0
@@ -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, hostAccess, inputApp, InputControl } from "./input.js";
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 and an H.264 stream on a loopback port; the phone opens it
12
- // through the same encrypted forwarder as port previews, so the video never
13
- // leaves the end-to-end channel and needs no NAT traversal. A random token
14
- // gates the port: other programs on this machine can't watch the screen
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 system allows recording to an app by name. A released package
19
- // carries one, LinkShell.app, and the capture runs as its child: the
20
- // 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.
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
- /** macOS: AVFoundation's "Capture screen N" devices. */
32
- async function listDisplays() {
33
- if (process.platform !== "darwin")
34
- return [{ index: 0, name: "屏幕", screen: 0 }];
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
- ...input,
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
- ...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),
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
- 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 });
@@ -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
- sockets.handleUpgrade(request, socket, head, (ws) => this.stream(ws, Number(url.searchParams.get("display") ?? NaN), relayed));
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, as whatever will do it
230
- * sees it: the app where the package has one, this process otherwise. With
231
- * `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.)
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 = app ? await app.access() : await hostAccess(this.log);
241
- // One question at a time: the picture first, then the hands.
242
- const missing = !status.recording ? "recording" : !status.trusted ? "control" : undefined;
243
- if (ask && missing)
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(index, level, on) {
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
- const data = (chunk) => {
258
- if (!ended)
259
- on.data(chunk);
260
- };
261
- const finished = (code, errors) => {
262
- leave();
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 system's question, and its settings, come up there.
274
- 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(() => { });
275
270
  on.exit(notAllowed(status.app));
276
271
  return undefined;
277
272
  }
278
- const stop = await app.capture(await ffmpegPath(), args, { data, exit: finished });
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
- stop();
297
+ stream.end();
298
+ leave();
285
299
  },
300
+ change: (next) => stream.set(rung(next)),
301
+ key: () => stream.key(),
286
302
  };
287
303
  }
288
- if (process.platform === "darwin") {
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", 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(index, level, on, wanted) {
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(index, level, on);
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
- stream(ws, display, relayed) {
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
- data: (chunk) => splitter.push(chunk, (unit, key) => {
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?.index ?? 0, level, events, () => !closed);
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
- this.log(`[screen] picture ${next > level ? "lighter" : "better"}: ${LADDER[next].width} wide, ${LADDER[next].fps} a second, ${LADDER[next].bitrate}`);
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: next > level });
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
- void begin().catch(failed);
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();