@linkshell/host 0.2.0 → 0.3.1

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.
@@ -0,0 +1,195 @@
1
+ import { z } from "zod";
2
+ /** What a viewer may ask the helper to do. Positions are fractions of the display being watched. */
3
+ export declare const inputEvent: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
4
+ t: z.ZodLiteral<"move">;
5
+ x: z.ZodNumber;
6
+ y: z.ZodNumber;
7
+ }, "strip", z.ZodTypeAny, {
8
+ t: "move";
9
+ x: number;
10
+ y: number;
11
+ }, {
12
+ t: "move";
13
+ x: number;
14
+ y: number;
15
+ }>, z.ZodObject<{
16
+ t: z.ZodLiteral<"down">;
17
+ b: z.ZodEnum<["left", "right"]>;
18
+ n: z.ZodOptional<z.ZodNumber>;
19
+ m: z.ZodOptional<z.ZodArray<z.ZodEnum<["cmd", "shift", "alt", "ctrl"]>, "many">>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ t: "down";
22
+ b: "left" | "right";
23
+ n?: number | undefined;
24
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
25
+ }, {
26
+ t: "down";
27
+ b: "left" | "right";
28
+ n?: number | undefined;
29
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
30
+ }>, z.ZodObject<{
31
+ t: z.ZodLiteral<"up">;
32
+ b: z.ZodEnum<["left", "right"]>;
33
+ n: z.ZodOptional<z.ZodNumber>;
34
+ m: z.ZodOptional<z.ZodArray<z.ZodEnum<["cmd", "shift", "alt", "ctrl"]>, "many">>;
35
+ }, "strip", z.ZodTypeAny, {
36
+ t: "up";
37
+ b: "left" | "right";
38
+ n?: number | undefined;
39
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
40
+ }, {
41
+ t: "up";
42
+ b: "left" | "right";
43
+ n?: number | undefined;
44
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
45
+ }>, z.ZodObject<{
46
+ t: z.ZodLiteral<"scroll">;
47
+ dx: z.ZodNumber;
48
+ dy: z.ZodNumber;
49
+ m: z.ZodOptional<z.ZodArray<z.ZodEnum<["cmd", "shift", "alt", "ctrl"]>, "many">>;
50
+ }, "strip", z.ZodTypeAny, {
51
+ t: "scroll";
52
+ dx: number;
53
+ dy: number;
54
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
55
+ }, {
56
+ t: "scroll";
57
+ dx: number;
58
+ dy: number;
59
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
60
+ }>, z.ZodObject<{
61
+ t: z.ZodLiteral<"key">;
62
+ k: z.ZodString;
63
+ m: z.ZodOptional<z.ZodArray<z.ZodEnum<["cmd", "shift", "alt", "ctrl"]>, "many">>;
64
+ }, "strip", z.ZodTypeAny, {
65
+ k: string;
66
+ t: "key";
67
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
68
+ }, {
69
+ k: string;
70
+ t: "key";
71
+ m?: ("shift" | "cmd" | "alt" | "ctrl")[] | undefined;
72
+ }>, z.ZodObject<{
73
+ t: z.ZodLiteral<"text">;
74
+ s: z.ZodString;
75
+ }, "strip", z.ZodTypeAny, {
76
+ t: "text";
77
+ s: string;
78
+ }, {
79
+ t: "text";
80
+ s: string;
81
+ }>, z.ZodObject<{
82
+ t: z.ZodLiteral<"prompt">;
83
+ }, "strip", z.ZodTypeAny, {
84
+ t: "prompt";
85
+ }, {
86
+ t: "prompt";
87
+ }>]>;
88
+ export type InputEvent = z.infer<typeof inputEvent>;
89
+ /** `app` is the name the system's privacy settings list: the one to allow. */
90
+ export type ControlState = {
91
+ available: true;
92
+ trusted: boolean;
93
+ app?: string;
94
+ } | {
95
+ available: false;
96
+ reason: string;
97
+ };
98
+ export interface InputListener {
99
+ state(state: ControlState): void;
100
+ /** Where the pointer is, when something other than this viewer moved it. */
101
+ cursor(x: number, y: number): void;
102
+ /** Dry runs only: the event that would have been posted. */
103
+ posted?(event: Record<string, unknown>): void;
104
+ }
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. */
122
+ export declare function inputHelper(log?: (message: string) => void): Promise<string>;
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 };
171
+ export declare class InputControl {
172
+ private readonly listener;
173
+ private readonly log;
174
+ /** `app: false` keeps to a child process even when the package has the app (tests). */
175
+ private readonly options;
176
+ private child?;
177
+ private joined?;
178
+ private stopped;
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?: {
183
+ dryRun?: boolean;
184
+ app?: boolean;
185
+ });
186
+ /** Starts the helper for the display ffmpeg calls "Capture screen `screen`". Reports through the listener; never throws. */
187
+ start(screen: number): Promise<void>;
188
+ private joinApp;
189
+ private spawn;
190
+ private heard;
191
+ /** Passes on one event from the viewer; anything that is not one is dropped. */
192
+ send(raw: unknown): void;
193
+ /** Ends this viewer's control; on its way out the helper lets go of any button still held. */
194
+ stop(): void;
195
+ }
@@ -0,0 +1,495 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { accessSync, chmodSync, constants, cpSync, existsSync, readFileSync, rmSync } from "node:fs";
4
+ import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:net";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import { fileURLToPath } from "node:url";
10
+ import { promisify } from "node:util";
11
+ import { z } from "zod";
12
+ import { INPUT_HELPER_SOURCE } from "./input-helper.js";
13
+ // Controlling the computer from the phone: the screen viewer sends pointer and
14
+ // key events down the same socket the picture comes up, and a small helper
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.
24
+ const run = promisify(execFile);
25
+ const unit = z.number().min(0).max(1);
26
+ const button = z.enum(["left", "right"]);
27
+ const modifiers = z.array(z.enum(["cmd", "shift", "alt", "ctrl"])).max(4).optional();
28
+ const clicks = z.number().int().min(1).max(3).optional();
29
+ const delta = z.number().finite();
30
+ /** What a viewer may ask the helper to do. Positions are fractions of the display being watched. */
31
+ export const inputEvent = z.discriminatedUnion("t", [
32
+ z.object({ t: z.literal("move"), x: unit, y: unit }),
33
+ z.object({ t: z.literal("down"), b: button, n: clicks, m: modifiers }),
34
+ z.object({ t: z.literal("up"), b: button, n: clicks, m: modifiers }),
35
+ z.object({ t: z.literal("scroll"), dx: delta, dy: delta, m: modifiers }),
36
+ z.object({ t: z.literal("key"), k: z.string().min(1).max(16), m: modifiers }),
37
+ z.object({ t: z.literal("text"), s: z.string().min(1).max(4000) }),
38
+ /** Have the system ask, on the computer, for the permission to control it. */
39
+ z.object({ t: z.literal("prompt") }),
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
+ }
102
+ function binDir() {
103
+ return join(home(), "bin");
104
+ }
105
+ async function build(log) {
106
+ const hash = createHash("sha256").update(INPUT_HELPER_SOURCE).digest("hex").slice(0, 12);
107
+ const dir = binDir();
108
+ const path = join(dir, `input-${hash}`);
109
+ if (existsSync(path))
110
+ return path;
111
+ // Asking the `swiftc` shim on a Mac without the tools opens an install dialog there: look first.
112
+ const tools = await run("/usr/bin/xcode-select", ["-p"], { timeout: 5000 }).then(() => true, () => false);
113
+ if (!tools)
114
+ throw new Error("控制电脑需要 Xcode 命令行工具:在电脑上运行 xcode-select --install 后再试");
115
+ await mkdir(dir, { recursive: true });
116
+ const source = `${path}.swift`;
117
+ const partial = `${path}.${process.pid}.partial`;
118
+ await writeFile(source, INPUT_HELPER_SOURCE);
119
+ try {
120
+ await run("/usr/bin/swiftc", ["-O", "-swift-version", "5", "-o", partial, source], { timeout: 180_000 });
121
+ await rename(partial, path);
122
+ }
123
+ catch (error) {
124
+ log(`[screen] the input helper did not compile: ${error.stderr?.trim() || error.message}`);
125
+ throw new Error("控制组件没能在这台电脑上编译,详情见电脑上的 LinkShell 日志");
126
+ }
127
+ finally {
128
+ await rm(source, { force: true });
129
+ await rm(partial, { force: true });
130
+ }
131
+ // Helpers built from an earlier version's source.
132
+ for (const name of await readdir(dir).catch(() => [])) {
133
+ if (/^input-[0-9a-f]{12}$/.test(name) && name !== `input-${hash}`)
134
+ await rm(join(dir, name), { force: true });
135
+ }
136
+ log("[screen] built the input helper");
137
+ return path;
138
+ }
139
+ let compiled;
140
+ /** The helper compiled here; built on first use, once per version of its source. */
141
+ export function inputHelper(log = () => { }) {
142
+ compiled ??= build(log).catch((error) => {
143
+ compiled = undefined;
144
+ throw error;
145
+ });
146
+ return compiled;
147
+ }
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 ─────────────────────────────
361
+ export class InputControl {
362
+ listener;
363
+ log;
364
+ options;
365
+ child;
366
+ joined;
367
+ stopped = false;
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 = {}) {
372
+ this.listener = listener;
373
+ this.log = log;
374
+ this.options = options;
375
+ }
376
+ /** Starts the helper for the display ffmpeg calls "Capture screen `screen`". Reports through the listener; never throws. */
377
+ async start(screen) {
378
+ if (this.started || this.stopped)
379
+ return;
380
+ this.started = true;
381
+ if (process.platform !== "darwin") {
382
+ this.listener.state({ available: false, reason: "暂时只能控制 macOS 电脑" });
383
+ return;
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
+ }
397
+ let path;
398
+ try {
399
+ path = await childHelper(this.log, app);
400
+ }
401
+ catch (error) {
402
+ this.listener.state({ available: false, reason: error.message });
403
+ return;
404
+ }
405
+ if (this.stopped)
406
+ return;
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) {
431
+ const child = spawn(path, [String(screen), ...(dryRun ? ["--dry-run"] : [])], { stdio: ["pipe", "pipe", "ignore"] });
432
+ this.child = child;
433
+ child.on("error", (error) => {
434
+ this.log(`[screen] the input helper did not start: ${error.message}`);
435
+ this.listener.state({ available: false, reason: "控制组件没能启动" });
436
+ });
437
+ child.on("exit", () => {
438
+ if (this.child === child)
439
+ this.child = undefined;
440
+ });
441
+ // The viewer going away mid-write is not an error worth a crash.
442
+ child.stdin.on("error", () => { });
443
+ let named = "";
444
+ createInterface({ input: child.stdout }).on("line", (line) => {
445
+ const message = parse(line);
446
+ if (!message)
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 });
451
+ }
452
+ else if (message.t === "trusted")
453
+ this.listener.state({ available: true, trusted: message.on === true, app: named });
454
+ else
455
+ this.heard(message);
456
+ });
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
+ }
464
+ /** Passes on one event from the viewer; anything that is not one is dropped. */
465
+ send(raw) {
466
+ const parsed = inputEvent.safeParse(raw);
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)
479
+ stdin.write(`${JSON.stringify(parsed.data)}\n`);
480
+ }
481
+ /** Ends this viewer's control; on its way out the helper lets go of any button still held. */
482
+ stop() {
483
+ this.stopped = true;
484
+ if (this.joined) {
485
+ this.joined.app.leave(this.joined.id, this.joined.watcher);
486
+ this.joined = undefined;
487
+ }
488
+ this.child?.stdin?.end();
489
+ const child = this.child;
490
+ if (child)
491
+ setTimeout(() => child.exitCode === null && child.kill("SIGKILL"), 1000).unref();
492
+ this.child = undefined;
493
+ }
494
+ }
495
+ //# sourceMappingURL=input.js.map