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.
- package/dist/cli/src/commands/doctor.js +7 -0
- package/dist/cli/src/commands/doctor.js.map +1 -1
- package/dist/cli/src/commands/host.js +4 -0
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/screen.d.ts +16 -0
- package/dist/cli/src/commands/screen.js +135 -0
- package/dist/cli/src/commands/screen.js.map +1 -0
- package/dist/cli/src/index.js +18 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
- package/dist/host/src/fs.d.ts +3 -3
- package/dist/host/src/input-helper.js +504 -148
- package/dist/host/src/input-helper.js.map +1 -1
- package/dist/host/src/input.d.ts +80 -6
- package/dist/host/src/input.js +371 -35
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/rpc/server.js +1 -0
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen-pacer.d.ts +62 -0
- package/dist/host/src/screen-pacer.js +143 -0
- package/dist/host/src/screen-pacer.js.map +1 -0
- package/dist/host/src/screen-viewer.js +38 -7
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +27 -21
- package/dist/host/src/screen.js +203 -39
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/terminals.d.ts +1 -1
- package/dist/wire/src/rpc.d.ts +815 -774
- package/dist/wire/src/rpc.js +21 -0
- package/dist/wire/src/rpc.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/doctor.ts +7 -0
- package/src/commands/host.ts +5 -0
- package/src/commands/screen.ts +143 -0
- 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,
|
|
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"}
|
package/dist/host/src/input.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/host/src/input.js
CHANGED
|
@@ -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 {
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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(
|
|
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
|
|
71
|
-
/** The compiled
|
|
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
|
-
|
|
74
|
-
|
|
142
|
+
compiled ??= build(log).catch((error) => {
|
|
143
|
+
compiled = undefined;
|
|
75
144
|
throw error;
|
|
76
145
|
});
|
|
77
|
-
return
|
|
146
|
+
return compiled;
|
|
78
147
|
}
|
|
79
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
405
|
+
if (this.stopped)
|
|
108
406
|
return;
|
|
109
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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.
|
|
134
|
-
else
|
|
135
|
-
this.
|
|
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
|
|
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)
|