linkshell-cli 0.8.0 → 0.8.2

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 (36) hide show
  1. package/dist/cli/src/commands/doctor.js +7 -0
  2. package/dist/cli/src/commands/doctor.js.map +1 -1
  3. package/dist/cli/src/commands/host.js +4 -0
  4. package/dist/cli/src/commands/host.js.map +1 -1
  5. package/dist/cli/src/commands/screen.d.ts +16 -0
  6. package/dist/cli/src/commands/screen.js +135 -0
  7. package/dist/cli/src/commands/screen.js.map +1 -0
  8. package/dist/cli/src/index.js +18 -1
  9. package/dist/cli/src/index.js.map +1 -1
  10. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  11. package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
  12. package/dist/host/src/fs.d.ts +3 -3
  13. package/dist/host/src/input-helper.js +504 -148
  14. package/dist/host/src/input-helper.js.map +1 -1
  15. package/dist/host/src/input.d.ts +80 -6
  16. package/dist/host/src/input.js +371 -35
  17. package/dist/host/src/input.js.map +1 -1
  18. package/dist/host/src/rpc/server.js +1 -0
  19. package/dist/host/src/rpc/server.js.map +1 -1
  20. package/dist/host/src/screen-pacer.d.ts +62 -0
  21. package/dist/host/src/screen-pacer.js +143 -0
  22. package/dist/host/src/screen-pacer.js.map +1 -0
  23. package/dist/host/src/screen-viewer.js +38 -7
  24. package/dist/host/src/screen-viewer.js.map +1 -1
  25. package/dist/host/src/screen.d.ts +27 -21
  26. package/dist/host/src/screen.js +203 -39
  27. package/dist/host/src/screen.js.map +1 -1
  28. package/dist/host/src/terminals.d.ts +1 -1
  29. package/dist/wire/src/rpc.d.ts +815 -774
  30. package/dist/wire/src/rpc.js +21 -0
  31. package/dist/wire/src/rpc.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands/doctor.ts +7 -0
  34. package/src/commands/host.ts +5 -0
  35. package/src/commands/screen.ts +143 -0
  36. package/src/index.ts +19 -1
@@ -1 +1 @@
1
- {"version":3,"file":"input-helper.js","sourceRoot":"","sources":["../../../../host/src/input-helper.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,kEAAkE;AAElE,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyM5C,CAAC"}
1
+ {"version":3,"file":"input-helper.js","sourceRoot":"","sources":["../../../../host/src/input-helper.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,+CAA+C;AAC/C,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,0EAA0E;AAC1E,uEAAuE;AACvE,+EAA+E;AAC/E,8EAA8E;AAC9E,wDAAwD;AAExD,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwiB5C,CAAC"}
@@ -62,12 +62,12 @@ export declare const inputEvent: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
62
62
  k: z.ZodString;
63
63
  m: z.ZodOptional<z.ZodArray<z.ZodEnum<["cmd", "shift", "alt", "ctrl"]>, "many">>;
64
64
  }, "strip", z.ZodTypeAny, {
65
- k: string;
66
65
  t: "key";
66
+ k: string;
67
67
  m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
68
68
  }, {
69
- k: string;
70
69
  t: "key";
70
+ k: string;
71
71
  m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
72
72
  }>, z.ZodObject<{
73
73
  t: z.ZodLiteral<"text">;
@@ -86,9 +86,11 @@ export declare const inputEvent: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
86
86
  t: "prompt";
87
87
  }>]>;
88
88
  export type InputEvent = z.infer<typeof inputEvent>;
89
+ /** `app` is the name the system's privacy settings list: the one to allow. */
89
90
  export type ControlState = {
90
91
  available: true;
91
92
  trusted: boolean;
93
+ app?: string;
92
94
  } | {
93
95
  available: false;
94
96
  reason: string;
@@ -100,22 +102,94 @@ export interface InputListener {
100
102
  /** Dry runs only: the event that would have been posted. */
101
103
  posted?(event: Record<string, unknown>): void;
102
104
  }
103
- /** The compiled helper's path; built on first use, once per version of its source. */
105
+ /** What the helper says of itself: what it may do, and under which app's name. */
106
+ export interface HelperStatus {
107
+ trusted: boolean;
108
+ recording: boolean;
109
+ app: string;
110
+ }
111
+ /**
112
+ * The app as it came in the package, made runnable. Packing a package drops
113
+ * the program's permission to run (pnpm stores every file that isn't a `bin`
114
+ * as a plain one): it is given back here, and where the installation can't be
115
+ * written to (a root-owned one), a copy under the user's own directory is used.
116
+ * Neither touches the signature.
117
+ */
118
+ export declare function usableApp(packaged: string, copyTo?: string): string | undefined;
119
+ /** The signed app this package was made with, if it was. */
120
+ export declare function shippedApp(): string | undefined;
121
+ /** The helper compiled here; built on first use, once per version of its source. */
104
122
  export declare function inputHelper(log?: (message: string) => void): Promise<string>;
105
- /** One viewer's control of one display. */
123
+ /**
124
+ * What this process itself (so: the host, and the capture it starts) is
125
+ * allowed to do, and under which app's name. `ask` has the system put its
126
+ * question for that permission, and opens the settings page for it.
127
+ */
128
+ export declare function hostAccess(log: (message: string) => void, ask?: "recording" | "control"): Promise<HelperStatus>;
129
+ type Route = (message: Record<string, unknown>) => void;
130
+ declare class HelperApp {
131
+ private readonly app;
132
+ private readonly dryRun;
133
+ private readonly log;
134
+ private server?;
135
+ private socket?;
136
+ private opening?;
137
+ private status?;
138
+ private readonly routes;
139
+ private readonly watchers;
140
+ private waiting;
141
+ constructor(app: string, dryRun: boolean, log: (message: string) => void);
142
+ /** The app's end of the socket; opens the app when it isn't there. */
143
+ private link;
144
+ private open;
145
+ private adopt;
146
+ private write;
147
+ /** What the app may do. It says so as it connects; asked again, it looks again. */
148
+ access(): Promise<HelperStatus>;
149
+ /** The system's dialog and its settings page, for the app. */
150
+ ask(what?: "control" | "recording"): Promise<void>;
151
+ /**
152
+ * Has the app run a screen capture: as the app's child, the recording is the
153
+ * app's to be allowed. The picture comes down a socket of its own; the
154
+ * returned function ends the capture.
155
+ */
156
+ capture(exec: string, args: string[], on: {
157
+ data(chunk: Buffer): void;
158
+ exit(code: number, errors: string): void;
159
+ }): Promise<() => void>;
160
+ join(id: string, screen: number, route: Route, watcher: (status: HelperStatus) => void): Promise<void>;
161
+ send(id: string, event: InputEvent): void;
162
+ leave(id: string, watcher: (status: HelperStatus) => void): void;
163
+ /** The host is going: the app leaves when its socket closes. */
164
+ close(): void;
165
+ }
166
+ /** Ends the helper app, if this process opened one. */
167
+ export declare function closeInputApp(): void;
168
+ /** The signed app, opened when it isn't yet; undefined where the package has none. */
169
+ export declare function inputApp(log: (message: string) => void): HelperApp | undefined;
170
+ export type { HelperApp };
106
171
  export declare class InputControl {
107
172
  private readonly listener;
108
173
  private readonly log;
174
+ /** `app: false` keeps to a child process even when the package has the app (tests). */
109
175
  private readonly options;
110
176
  private child?;
177
+ private joined?;
111
178
  private stopped;
112
- constructor(listener: InputListener, log: (message: string) => void, options?: {
179
+ private started;
180
+ constructor(listener: InputListener, log: (message: string) => void,
181
+ /** `app: false` keeps to a child process even when the package has the app (tests). */
182
+ options?: {
113
183
  dryRun?: boolean;
184
+ app?: boolean;
114
185
  });
115
186
  /** Starts the helper for the display ffmpeg calls "Capture screen `screen`". Reports through the listener; never throws. */
116
187
  start(screen: number): Promise<void>;
188
+ private joinApp;
189
+ private spawn;
190
+ private heard;
117
191
  /** Passes on one event from the viewer; anything that is not one is dropped. */
118
192
  send(raw: unknown): void;
119
- /** Ends the helper; on its way out it lets go of any button still held. */
193
+ /** Ends this viewer's control; on its way out the helper lets go of any button still held. */
120
194
  stop(): void;
121
195
  }
@@ -1,18 +1,26 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync } from "node:fs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { accessSync, chmodSync, constants, cpSync, existsSync, readFileSync, rmSync } from "node:fs";
4
4
  import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
5
+ import { createServer } from "node:net";
6
+ import { homedir, tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
8
  import { createInterface } from "node:readline";
9
+ import { fileURLToPath } from "node:url";
8
10
  import { promisify } from "node:util";
9
11
  import { z } from "zod";
10
12
  import { INPUT_HELPER_SOURCE } from "./input-helper.js";
11
13
  // Controlling the computer from the phone: the screen viewer sends pointer and
12
14
  // key events down the same socket the picture comes up, and a small helper
13
- // posts them to the system. macOS only; the helper needs the Accessibility
14
- // permission, which the system grants to whatever started the host (the
15
- // terminal, as with Screen Recording).
15
+ // posts them to the system. macOS only, and the system has to allow it
16
+ // (Privacy & Security › Accessibility).
17
+ //
18
+ // A released package carries the helper as LinkShell.app, signed. The host has
19
+ // the system open it, which makes it an app of its own: the permission is
20
+ // asked for and kept under the name LinkShell, whichever terminal started the
21
+ // host and however often the CLI is upgraded. A build without the app (a
22
+ // checkout with no signing identity) compiles the same source on first use and
23
+ // runs it as the host's child; the permission is then the terminal's.
16
24
  const run = promisify(execFile);
17
25
  const unit = z.number().min(0).max(1);
18
26
  const button = z.enum(["left", "right"]);
@@ -30,8 +38,69 @@ export const inputEvent = z.discriminatedUnion("t", [
30
38
  /** Have the system ask, on the computer, for the permission to control it. */
31
39
  z.object({ t: z.literal("prompt") }),
32
40
  ]);
41
+ const dryRunByDefault = () => process.env.LINKSHELL_INPUT_DRY_RUN === "1";
42
+ // ── Where the helper is ─────────────────────────────────────────────
43
+ const home = () => process.env.LINKSHELL_HOME || join(homedir(), ".linkshell");
44
+ const programOf = (app) => join(app, "Contents/MacOS/LinkShell");
45
+ function runnable(path) {
46
+ try {
47
+ accessSync(path, constants.X_OK);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * The app as it came in the package, made runnable. Packing a package drops
56
+ * the program's permission to run (pnpm stores every file that isn't a `bin`
57
+ * as a plain one): it is given back here, and where the installation can't be
58
+ * written to (a root-owned one), a copy under the user's own directory is used.
59
+ * Neither touches the signature.
60
+ */
61
+ export function usableApp(packaged, copyTo = join(home(), "LinkShell.app")) {
62
+ if (runnable(programOf(packaged)))
63
+ return packaged;
64
+ try {
65
+ chmodSync(programOf(packaged), 0o755);
66
+ return packaged;
67
+ }
68
+ catch {
69
+ // Not ours to change: fall through to a copy.
70
+ }
71
+ try {
72
+ const plist = (app) => readFileSync(join(app, "Contents/Info.plist"), "utf8");
73
+ if (!existsSync(programOf(copyTo)) || plist(copyTo) !== plist(packaged)) {
74
+ rmSync(copyTo, { recursive: true, force: true });
75
+ cpSync(packaged, copyTo, { recursive: true });
76
+ }
77
+ chmodSync(programOf(copyTo), 0o755);
78
+ return copyTo;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ let found;
85
+ /** The signed app this package was made with, if it was. */
86
+ export function shippedApp() {
87
+ if (process.platform !== "darwin" || process.env.LINKSHELL_INPUT_APP === "off")
88
+ return undefined;
89
+ if (found)
90
+ return found.app;
91
+ found = { app: undefined };
92
+ // Beside `dist` in a package (the host's own, or the CLI's, which compiles the host in); beside `src` in a checkout.
93
+ for (const relative of ["../../../helper/LinkShell.app", "../helper/LinkShell.app"]) {
94
+ const path = fileURLToPath(new URL(relative, import.meta.url));
95
+ if (existsSync(programOf(path))) {
96
+ found.app = usableApp(path);
97
+ break;
98
+ }
99
+ }
100
+ return found.app;
101
+ }
33
102
  function binDir() {
34
- return join(process.env.LINKSHELL_HOME || join(homedir(), ".linkshell"), "bin");
103
+ return join(home(), "bin");
35
104
  }
36
105
  async function build(log) {
37
106
  const hash = createHash("sha256").update(INPUT_HELPER_SOURCE).digest("hex").slice(0, 12);
@@ -67,46 +136,298 @@ async function build(log) {
67
136
  log("[screen] built the input helper");
68
137
  return path;
69
138
  }
70
- let helper;
71
- /** The compiled helper's path; built on first use, once per version of its source. */
139
+ let compiled;
140
+ /** The helper compiled here; built on first use, once per version of its source. */
72
141
  export function inputHelper(log = () => { }) {
73
- helper ??= build(log).catch((error) => {
74
- helper = undefined;
142
+ compiled ??= build(log).catch((error) => {
143
+ compiled = undefined;
75
144
  throw error;
76
145
  });
77
- return helper;
146
+ return compiled;
78
147
  }
79
- /** One viewer's control of one display. */
148
+ /** The helper to run as this process's child: the app's own program, or the one compiled here. */
149
+ function childHelper(log, app) {
150
+ return app ? Promise.resolve(programOf(app)) : inputHelper(log);
151
+ }
152
+ function parse(line) {
153
+ try {
154
+ const message = JSON.parse(line);
155
+ return message && typeof message === "object" ? message : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ }
161
+ function statusOf(message) {
162
+ return { trusted: message.trusted === true, recording: message.recording === true, app: typeof message.app === "string" ? message.app : "" };
163
+ }
164
+ /**
165
+ * What this process itself (so: the host, and the capture it starts) is
166
+ * allowed to do, and under which app's name. `ask` has the system put its
167
+ * question for that permission, and opens the settings page for it.
168
+ */
169
+ export async function hostAccess(log, ask) {
170
+ const helper = await childHelper(log, shippedApp());
171
+ const { stdout } = await run(helper, [ask ? `--ask-${ask}` : "--status"], { timeout: 15_000 });
172
+ const message = parse(stdout.trim().split("\n").pop() ?? "");
173
+ if (!message)
174
+ throw new Error("the input helper gave no answer");
175
+ return statusOf(message);
176
+ }
177
+ class HelperApp {
178
+ app;
179
+ dryRun;
180
+ log;
181
+ server;
182
+ socket;
183
+ opening;
184
+ status;
185
+ routes = new Map();
186
+ watchers = new Set();
187
+ waiting = [];
188
+ constructor(app, dryRun, log) {
189
+ this.app = app;
190
+ this.dryRun = dryRun;
191
+ this.log = log;
192
+ }
193
+ /** The app's end of the socket; opens the app when it isn't there. */
194
+ link() {
195
+ if (this.socket && !this.socket.destroyed)
196
+ return Promise.resolve(this.socket);
197
+ this.opening ??= this.open().finally(() => (this.opening = undefined));
198
+ return this.opening;
199
+ }
200
+ open() {
201
+ // The app comes to us: a socket only this user can reach, in a directory of its own name.
202
+ const path = join(tmpdir(), `linkshell-input-${process.pid}-${randomBytes(4).toString("hex")}.sock`);
203
+ return new Promise((resolve, reject) => {
204
+ const server = createServer();
205
+ const fail = (error) => {
206
+ clearTimeout(timer);
207
+ server.close();
208
+ rmSync(path, { force: true });
209
+ reject(error);
210
+ };
211
+ const timer = setTimeout(() => fail(new Error("LinkShell.app did not start")), 15_000);
212
+ server.once("error", fail);
213
+ server.once("connection", (socket) => {
214
+ clearTimeout(timer);
215
+ // One app, one connection: nothing else gets in.
216
+ server.close();
217
+ rmSync(path, { force: true });
218
+ this.server = undefined;
219
+ this.adopt(socket);
220
+ resolve(socket);
221
+ });
222
+ server.listen(path, () => {
223
+ chmodSync(path, 0o600);
224
+ // Opened by the system, not started as a child: that is what makes it an app of its own to the privacy settings.
225
+ execFile("/usr/bin/open", ["-n", "-g", "-a", this.app, "--args", "--connect", path, ...(this.dryRun ? ["--dry-run"] : [])], (error) => {
226
+ if (error)
227
+ fail(new Error(`LinkShell.app could not be opened: ${error.message}`));
228
+ });
229
+ });
230
+ this.server = server;
231
+ });
232
+ }
233
+ adopt(socket) {
234
+ this.socket = socket;
235
+ socket.on("error", () => { });
236
+ socket.on("close", () => {
237
+ if (this.socket !== socket)
238
+ return;
239
+ this.socket = undefined;
240
+ this.status = undefined;
241
+ // The app went (quit, or crashed): each viewer hears it, and the next one to ask opens it again.
242
+ for (const route of this.routes.values())
243
+ route({ t: "gone" });
244
+ this.routes.clear();
245
+ });
246
+ createInterface({ input: socket }).on("line", (line) => {
247
+ const message = parse(line);
248
+ if (!message)
249
+ return;
250
+ if (message.t === "status") {
251
+ this.status = statusOf(message);
252
+ for (const waiter of this.waiting.splice(0))
253
+ waiter(this.status);
254
+ }
255
+ else if (message.t === "trusted" && this.status) {
256
+ this.status = { ...this.status, trusted: message.on === true };
257
+ for (const watcher of this.watchers)
258
+ watcher(this.status);
259
+ }
260
+ else if (typeof message.v === "string") {
261
+ this.routes.get(message.v)?.(message);
262
+ }
263
+ });
264
+ }
265
+ async write(message) {
266
+ const socket = await this.link();
267
+ socket.write(`${JSON.stringify(message)}\n`);
268
+ }
269
+ /** What the app may do. It says so as it connects; asked again, it looks again. */
270
+ async access() {
271
+ await this.link();
272
+ if (this.status) {
273
+ const answer = new Promise((resolve) => this.waiting.push(resolve));
274
+ await this.write({ t: "status" });
275
+ return answer;
276
+ }
277
+ return new Promise((resolve) => this.waiting.push(resolve));
278
+ }
279
+ /** The system's dialog and its settings page, for the app. */
280
+ ask(what = "control") {
281
+ return this.write({ t: what === "recording" ? "ask-recording" : "ask" });
282
+ }
283
+ /**
284
+ * Has the app run a screen capture: as the app's child, the recording is the
285
+ * app's to be allowed. The picture comes down a socket of its own; the
286
+ * returned function ends the capture.
287
+ */
288
+ async capture(exec, args, on) {
289
+ await this.link();
290
+ const id = `capture-${randomBytes(6).toString("hex")}`;
291
+ const path = join(tmpdir(), `linkshell-capture-${process.pid}-${randomBytes(4).toString("hex")}.sock`);
292
+ const server = createServer((socket) => {
293
+ server.close();
294
+ rmSync(path, { force: true });
295
+ socket.on("error", () => { });
296
+ socket.on("data", on.data);
297
+ });
298
+ let over = false;
299
+ this.routes.set(id, (message) => {
300
+ if (over || (message.t !== "captured" && message.t !== "gone"))
301
+ return;
302
+ over = true;
303
+ this.routes.delete(id);
304
+ server.close();
305
+ rmSync(path, { force: true });
306
+ on.exit(typeof message.code === "number" ? message.code : -1, typeof message.errors === "string" ? message.errors : "");
307
+ });
308
+ await new Promise((resolve, reject) => {
309
+ server.once("error", reject);
310
+ server.listen(path, () => {
311
+ chmodSync(path, 0o600);
312
+ resolve();
313
+ });
314
+ });
315
+ await this.write({ t: "capture", v: id, exec, args, socket: path });
316
+ return () => {
317
+ if (!over && this.socket && !this.socket.destroyed)
318
+ this.socket.write(`${JSON.stringify({ t: "stop", v: id })}\n`);
319
+ };
320
+ }
321
+ async join(id, screen, route, watcher) {
322
+ this.routes.set(id, route);
323
+ this.watchers.add(watcher);
324
+ await this.write({ t: "open", v: id, screen });
325
+ }
326
+ send(id, event) {
327
+ if (this.socket && !this.socket.destroyed && this.routes.has(id))
328
+ this.socket.write(`${JSON.stringify({ ...event, v: id })}\n`);
329
+ }
330
+ leave(id, watcher) {
331
+ this.watchers.delete(watcher);
332
+ if (!this.routes.has(id))
333
+ return;
334
+ if (this.socket && !this.socket.destroyed)
335
+ this.socket.write(`${JSON.stringify({ t: "close", v: id })}\n`);
336
+ // What the app says as it lets go (a dry run's last report) still finds its viewer.
337
+ setTimeout(() => this.routes.delete(id), 1000).unref();
338
+ }
339
+ /** The host is going: the app leaves when its socket closes. */
340
+ close() {
341
+ this.server?.close();
342
+ this.socket?.destroy();
343
+ }
344
+ }
345
+ let shared;
346
+ function helperApp(app, dryRun, log) {
347
+ shared ??= new HelperApp(app, dryRun, log);
348
+ return shared;
349
+ }
350
+ /** Ends the helper app, if this process opened one. */
351
+ export function closeInputApp() {
352
+ shared?.close();
353
+ shared = undefined;
354
+ }
355
+ /** The signed app, opened when it isn't yet; undefined where the package has none. */
356
+ export function inputApp(log) {
357
+ const app = shippedApp();
358
+ return app ? helperApp(app, dryRunByDefault(), log) : undefined;
359
+ }
360
+ // ── One viewer's control of one display ─────────────────────────────
80
361
  export class InputControl {
81
362
  listener;
82
363
  log;
83
364
  options;
84
365
  child;
366
+ joined;
85
367
  stopped = false;
86
- constructor(listener, log, options = {}) {
368
+ started = false;
369
+ constructor(listener, log,
370
+ /** `app: false` keeps to a child process even when the package has the app (tests). */
371
+ options = {}) {
87
372
  this.listener = listener;
88
373
  this.log = log;
89
374
  this.options = options;
90
375
  }
91
376
  /** Starts the helper for the display ffmpeg calls "Capture screen `screen`". Reports through the listener; never throws. */
92
377
  async start(screen) {
93
- if (this.child || this.stopped)
378
+ if (this.started || this.stopped)
94
379
  return;
380
+ this.started = true;
95
381
  if (process.platform !== "darwin") {
96
382
  this.listener.state({ available: false, reason: "暂时只能控制 macOS 电脑" });
97
383
  return;
98
384
  }
385
+ const dryRun = this.options.dryRun ?? dryRunByDefault();
386
+ const app = this.options.app === false ? undefined : shippedApp();
387
+ if (app) {
388
+ try {
389
+ await this.joinApp(helperApp(app, dryRun, this.log), screen);
390
+ return;
391
+ }
392
+ catch (error) {
393
+ // The app's own program still works as a child of the host: the permission is then the terminal's.
394
+ this.log(`[screen] ${error.message}; controlling as the host's own process instead`);
395
+ }
396
+ }
99
397
  let path;
100
398
  try {
101
- path = await inputHelper(this.log);
399
+ path = await childHelper(this.log, app);
102
400
  }
103
401
  catch (error) {
104
402
  this.listener.state({ available: false, reason: error.message });
105
403
  return;
106
404
  }
107
- if (this.child || this.stopped)
405
+ if (this.stopped)
108
406
  return;
109
- const dryRun = this.options.dryRun ?? process.env.LINKSHELL_INPUT_DRY_RUN === "1";
407
+ this.spawn(path, screen, dryRun);
408
+ }
409
+ async joinApp(app, screen) {
410
+ const id = randomBytes(6).toString("hex");
411
+ let named = "";
412
+ const watcher = (status) => this.listener.state({ available: true, trusted: status.trusted, app: named || status.app });
413
+ await app.join(id, screen, (message) => {
414
+ if (message.t === "ready") {
415
+ named = typeof message.app === "string" ? message.app : "";
416
+ this.listener.state({ available: true, trusted: message.trusted === true, app: named });
417
+ }
418
+ else if (message.t === "gone") {
419
+ this.joined = undefined;
420
+ this.started = false;
421
+ this.listener.state({ available: false, reason: "电脑上的 LinkShell 控制组件退出了,重新选择控制方式即可" });
422
+ }
423
+ else
424
+ this.heard(message);
425
+ }, watcher);
426
+ this.joined = { app, id, watcher };
427
+ if (this.stopped)
428
+ this.stop();
429
+ }
430
+ spawn(path, screen, dryRun) {
110
431
  const child = spawn(path, [String(screen), ...(dryRun ? ["--dry-run"] : [])], { stdio: ["pipe", "pipe", "ignore"] });
111
432
  this.child = child;
112
433
  child.on("error", (error) => {
@@ -119,36 +440,51 @@ export class InputControl {
119
440
  });
120
441
  // The viewer going away mid-write is not an error worth a crash.
121
442
  child.stdin.on("error", () => { });
443
+ let named = "";
122
444
  createInterface({ input: child.stdout }).on("line", (line) => {
123
- let message;
124
- try {
125
- message = JSON.parse(line);
126
- }
127
- catch {
445
+ const message = parse(line);
446
+ if (!message)
128
447
  return;
448
+ if (message.t === "ready") {
449
+ named = typeof message.app === "string" ? message.app : "";
450
+ this.listener.state({ available: true, trusted: message.trusted === true, app: named });
129
451
  }
130
- if (message.t === "ready")
131
- this.listener.state({ available: true, trusted: message.trusted === true });
132
452
  else if (message.t === "trusted")
133
- this.listener.state({ available: true, trusted: message.v === true });
134
- else if (message.t === "cursor" && typeof message.x === "number" && typeof message.y === "number")
135
- this.listener.cursor(message.x, message.y);
136
- else if (message.t === "posted")
137
- this.listener.posted?.(message);
453
+ this.listener.state({ available: true, trusted: message.on === true, app: named });
454
+ else
455
+ this.heard(message);
138
456
  });
139
457
  }
458
+ heard(message) {
459
+ if (message.t === "cursor" && typeof message.x === "number" && typeof message.y === "number")
460
+ this.listener.cursor(message.x, message.y);
461
+ else if (message.t === "posted")
462
+ this.listener.posted?.(message);
463
+ }
140
464
  /** Passes on one event from the viewer; anything that is not one is dropped. */
141
465
  send(raw) {
142
- const stdin = this.child?.stdin;
143
- if (!stdin?.writable)
144
- return;
145
466
  const parsed = inputEvent.safeParse(raw);
146
- if (parsed.success)
467
+ if (!parsed.success)
468
+ return;
469
+ if (this.joined) {
470
+ // Asking opens the settings too, which the app does for itself.
471
+ if (parsed.data.t === "prompt")
472
+ void this.joined.app.ask().catch(() => { });
473
+ else
474
+ this.joined.app.send(this.joined.id, parsed.data);
475
+ return;
476
+ }
477
+ const stdin = this.child?.stdin;
478
+ if (stdin?.writable)
147
479
  stdin.write(`${JSON.stringify(parsed.data)}\n`);
148
480
  }
149
- /** Ends the helper; on its way out it lets go of any button still held. */
481
+ /** Ends this viewer's control; on its way out the helper lets go of any button still held. */
150
482
  stop() {
151
483
  this.stopped = true;
484
+ if (this.joined) {
485
+ this.joined.app.leave(this.joined.id, this.joined.watcher);
486
+ this.joined = undefined;
487
+ }
152
488
  this.child?.stdin?.end();
153
489
  const child = this.child;
154
490
  if (child)