linkshell-cli 0.1.16 → 0.2.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,235 @@
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { readFileSync, unlinkSync, existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { platform } from "node:os";
6
+ import { createEnvelope, serializeEnvelope } from "@linkshell/protocol";
7
+ import type { Envelope } from "@linkshell/protocol";
8
+
9
+ export interface ScreenFallbackOptions {
10
+ fps: number;
11
+ quality: number;
12
+ scale: number;
13
+ sessionId: string;
14
+ onFrame: (envelope: Envelope) => void;
15
+ onStatus: (envelope: Envelope) => void;
16
+ }
17
+
18
+ const CHUNK_SIZE = 48 * 1024; // 48KB per chunk, leave room for envelope overhead
19
+ const TMP_FILE = join(tmpdir(), `linkshell-screen-${process.pid}.jpg`);
20
+
21
+ export class ScreenFallback {
22
+ private timer: ReturnType<typeof setInterval> | undefined;
23
+ private frameId = 0;
24
+ private active = false;
25
+ private readonly options: ScreenFallbackOptions;
26
+
27
+ constructor(options: ScreenFallbackOptions) {
28
+ this.options = options;
29
+ }
30
+
31
+ start(): void {
32
+ if (this.active) return;
33
+ this.active = true;
34
+ this.frameId = 0;
35
+
36
+ const interval = Math.max(50, Math.floor(1000 / this.options.fps));
37
+
38
+ this.options.onStatus(
39
+ createEnvelope({
40
+ type: "screen.status",
41
+ sessionId: this.options.sessionId,
42
+ payload: { active: true, mode: "fallback" as const },
43
+ }),
44
+ );
45
+
46
+ // Capture first frame immediately
47
+ this.captureAndSend();
48
+
49
+ this.timer = setInterval(() => {
50
+ this.captureAndSend();
51
+ }, interval);
52
+ }
53
+
54
+ stop(): void {
55
+ if (!this.active) return;
56
+ this.active = false;
57
+ if (this.timer) {
58
+ clearInterval(this.timer);
59
+ this.timer = undefined;
60
+ }
61
+ // Clean up temp file
62
+ try { if (existsSync(TMP_FILE)) unlinkSync(TMP_FILE); } catch {}
63
+
64
+ this.options.onStatus(
65
+ createEnvelope({
66
+ type: "screen.status",
67
+ sessionId: this.options.sessionId,
68
+ payload: { active: false, mode: "off" as const },
69
+ }),
70
+ );
71
+ }
72
+
73
+ private captureAndSend(): void {
74
+ if (!this.active) return;
75
+
76
+ try {
77
+ const imgBuffer = this.captureScreen();
78
+ if (!imgBuffer || imgBuffer.length === 0) return;
79
+
80
+ const base64 = imgBuffer.toString("base64");
81
+ const frameId = this.frameId++;
82
+
83
+ // Get dimensions (approximate from JPEG header or use defaults)
84
+ const dims = this.getJpegDimensions(imgBuffer);
85
+
86
+ if (base64.length <= CHUNK_SIZE) {
87
+ // Single chunk
88
+ this.options.onFrame(
89
+ createEnvelope({
90
+ type: "screen.frame",
91
+ sessionId: this.options.sessionId,
92
+ payload: {
93
+ data: base64,
94
+ width: dims.width,
95
+ height: dims.height,
96
+ frameId,
97
+ chunkIndex: 0,
98
+ chunkTotal: 1,
99
+ },
100
+ }),
101
+ );
102
+ } else {
103
+ // Split into chunks
104
+ const chunkTotal = Math.ceil(base64.length / CHUNK_SIZE);
105
+ for (let i = 0; i < chunkTotal; i++) {
106
+ if (!this.active) return;
107
+ const chunk = base64.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
108
+ this.options.onFrame(
109
+ createEnvelope({
110
+ type: "screen.frame",
111
+ sessionId: this.options.sessionId,
112
+ payload: {
113
+ data: chunk,
114
+ width: dims.width,
115
+ height: dims.height,
116
+ frameId,
117
+ chunkIndex: i,
118
+ chunkTotal,
119
+ },
120
+ }),
121
+ );
122
+ }
123
+ }
124
+ } catch {
125
+ // Silently skip failed frames
126
+ }
127
+ }
128
+
129
+ private captureScreen(): Buffer | null {
130
+ const os = platform();
131
+ const q = this.options.quality;
132
+ const s = this.options.scale;
133
+
134
+ try {
135
+ if (os === "darwin") {
136
+ // macOS: screencapture
137
+ execSync(
138
+ `screencapture -x -t jpg -C "${TMP_FILE}"`,
139
+ { timeout: 3000, stdio: "pipe" },
140
+ );
141
+ if (!existsSync(TMP_FILE)) return null;
142
+ let buf = readFileSync(TMP_FILE);
143
+
144
+ // Resize if scale < 1 and sips is available
145
+ if (s < 1) {
146
+ try {
147
+ const w = Math.floor(this.getJpegDimensions(buf).width * s);
148
+ execSync(
149
+ `sips --resampleWidth ${w} -s formatOptions ${q} "${TMP_FILE}" --out "${TMP_FILE}"`,
150
+ { timeout: 3000, stdio: "pipe" },
151
+ );
152
+ buf = readFileSync(TMP_FILE);
153
+ } catch {
154
+ // Use original size if resize fails
155
+ }
156
+ }
157
+ return buf;
158
+ }
159
+
160
+ if (os === "linux") {
161
+ // Linux: try import (ImageMagick) or scrot
162
+ try {
163
+ const resizeArg = s < 1 ? `-resize ${Math.floor(s * 100)}%` : "";
164
+ execSync(
165
+ `import -window root -quality ${q} ${resizeArg} jpeg:"${TMP_FILE}"`,
166
+ { timeout: 3000, stdio: "pipe" },
167
+ );
168
+ } catch {
169
+ // Fallback to scrot
170
+ execSync(
171
+ `scrot -q ${q} "${TMP_FILE}"`,
172
+ { timeout: 3000, stdio: "pipe" },
173
+ );
174
+ }
175
+ if (!existsSync(TMP_FILE)) return null;
176
+ return readFileSync(TMP_FILE);
177
+ }
178
+
179
+ // Windows: use PowerShell
180
+ if (os === "win32") {
181
+ execSync(
182
+ `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $bmp = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height); $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size); $bmp.Save('${TMP_FILE.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Jpeg) }"`,
183
+ { timeout: 5000, stdio: "pipe" },
184
+ );
185
+ if (!existsSync(TMP_FILE)) return null;
186
+ return readFileSync(TMP_FILE);
187
+ }
188
+
189
+ return null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ private getJpegDimensions(buf: Buffer): { width: number; height: number } {
196
+ // Parse JPEG SOF marker for dimensions
197
+ try {
198
+ let i = 2; // Skip SOI
199
+ while (i < buf.length - 8) {
200
+ if (buf[i] !== 0xff) break;
201
+ const marker = buf[i + 1]!;
202
+ // SOF0, SOF1, SOF2
203
+ if (marker >= 0xc0 && marker <= 0xc2) {
204
+ const height = buf.readUInt16BE(i + 5);
205
+ const width = buf.readUInt16BE(i + 7);
206
+ return { width, height };
207
+ }
208
+ const len = buf.readUInt16BE(i + 2);
209
+ i += 2 + len;
210
+ }
211
+ } catch {}
212
+ return { width: 1920, height: 1080 }; // Default fallback
213
+ }
214
+
215
+ static isAvailable(): boolean {
216
+ const os = platform();
217
+ try {
218
+ if (os === "darwin") {
219
+ execSync("which screencapture", { stdio: "pipe" });
220
+ return true;
221
+ }
222
+ if (os === "linux") {
223
+ try {
224
+ execSync("which import", { stdio: "pipe" });
225
+ return true;
226
+ } catch {
227
+ execSync("which scrot", { stdio: "pipe" });
228
+ return true;
229
+ }
230
+ }
231
+ if (os === "win32") return true; // PowerShell always available
232
+ } catch {}
233
+ return false;
234
+ }
235
+ }
@@ -0,0 +1,283 @@
1
+ import { spawn, execSync } from "node:child_process";
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { platform } from "node:os";
4
+ import { createEnvelope } from "@linkshell/protocol";
5
+ import type { Envelope } from "@linkshell/protocol";
6
+
7
+ export interface ScreenShareOptions {
8
+ sessionId: string;
9
+ fps: number;
10
+ quality: number;
11
+ scale: number;
12
+ turnUrl?: string;
13
+ turnUser?: string;
14
+ turnPass?: string;
15
+ onSignal: (envelope: Envelope) => void;
16
+ onStatus: (envelope: Envelope) => void;
17
+ }
18
+
19
+ /**
20
+ * WebRTC screen sharing using werift (pure TS WebRTC) + ffmpeg.
21
+ *
22
+ * Flow:
23
+ * 1. ffmpeg captures screen → H.264 → writes to stdout as raw annexb
24
+ * 2. We read H.264 NAL units and push them into werift video track
25
+ * 3. werift handles ICE/DTLS/SRTP and sends to remote peer
26
+ * 4. Signaling (SDP offer/answer, ICE candidates) goes through existing WebSocket
27
+ */
28
+ export class ScreenShare {
29
+ private pc: any; // werift.RTCPeerConnection
30
+ private ffmpeg: ChildProcess | undefined;
31
+ private active = false;
32
+ private readonly options: ScreenShareOptions;
33
+
34
+ constructor(options: ScreenShareOptions) {
35
+ this.options = options;
36
+ }
37
+
38
+ static isAvailable(): boolean {
39
+ // Check if werift can be imported and ffmpeg exists
40
+ try {
41
+ execSync("which ffmpeg", { stdio: "pipe" });
42
+ } catch {
43
+ return false;
44
+ }
45
+ try {
46
+ require.resolve("werift");
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async start(): Promise<void> {
54
+ if (this.active) return;
55
+ this.active = true;
56
+
57
+ try {
58
+ const werift = await import("werift");
59
+
60
+ // ICE servers
61
+ const iceServers: any[] = [
62
+ { urls: "stun:stun.l.google.com:19302" },
63
+ ];
64
+ if (this.options.turnUrl) {
65
+ iceServers.push({
66
+ urls: this.options.turnUrl,
67
+ username: this.options.turnUser ?? "",
68
+ credential: this.options.turnPass ?? "",
69
+ });
70
+ }
71
+
72
+ this.pc = new werift.RTCPeerConnection({
73
+ iceServers,
74
+ bundlePolicy: "max-bundle",
75
+ });
76
+
77
+ // Create video track
78
+ const videoTrack = new werift.MediaStreamTrack({ kind: "video" });
79
+ this.pc.addTrack(videoTrack);
80
+
81
+ // ICE candidate → send to remote
82
+ this.pc.onIceCandidate.subscribe((candidate: any) => {
83
+ if (candidate) {
84
+ this.options.onSignal(
85
+ createEnvelope({
86
+ type: "screen.ice",
87
+ sessionId: this.options.sessionId,
88
+ payload: {
89
+ candidate: candidate.candidate,
90
+ sdpMid: candidate.sdpMid ?? null,
91
+ sdpMLineIndex: candidate.sdpMLineIndex ?? null,
92
+ },
93
+ }),
94
+ );
95
+ }
96
+ });
97
+
98
+ // Connection state changes
99
+ this.pc.connectionStateChange.subscribe((state: string) => {
100
+ if (state === "connected") {
101
+ this.options.onStatus(
102
+ createEnvelope({
103
+ type: "screen.status",
104
+ sessionId: this.options.sessionId,
105
+ payload: { active: true, mode: "webrtc" as const },
106
+ }),
107
+ );
108
+ } else if (state === "failed" || state === "disconnected" || state === "closed") {
109
+ this.options.onStatus(
110
+ createEnvelope({
111
+ type: "screen.status",
112
+ sessionId: this.options.sessionId,
113
+ payload: { active: false, mode: "off" as const, error: `WebRTC ${state}` },
114
+ }),
115
+ );
116
+ }
117
+ });
118
+
119
+ // Create offer
120
+ const offer = await this.pc.createOffer();
121
+ await this.pc.setLocalDescription(offer);
122
+
123
+ this.options.onSignal(
124
+ createEnvelope({
125
+ type: "screen.offer",
126
+ sessionId: this.options.sessionId,
127
+ payload: { sdp: offer.sdp },
128
+ }),
129
+ );
130
+
131
+ // Start ffmpeg capture (will begin sending once ICE connects)
132
+ this.startFfmpeg(videoTrack);
133
+
134
+ } catch (err) {
135
+ this.options.onStatus(
136
+ createEnvelope({
137
+ type: "screen.status",
138
+ sessionId: this.options.sessionId,
139
+ payload: {
140
+ active: false,
141
+ mode: "off" as const,
142
+ error: `WebRTC init failed: ${err instanceof Error ? err.message : String(err)}`,
143
+ },
144
+ }),
145
+ );
146
+ this.active = false;
147
+ }
148
+ }
149
+
150
+ async handleAnswer(sdp: string): Promise<void> {
151
+ if (!this.pc) return;
152
+ try {
153
+ const werift = await import("werift");
154
+ await this.pc.setRemoteDescription(
155
+ new werift.RTCSessionDescription(sdp, "answer"),
156
+ );
157
+ } catch (err) {
158
+ process.stderr.write(`[screen-share] failed to set answer: ${err}\n`);
159
+ }
160
+ }
161
+
162
+ async handleIceCandidate(candidate: string, sdpMid?: string | null, sdpMLineIndex?: number | null): Promise<void> {
163
+ if (!this.pc) return;
164
+ try {
165
+ const werift = await import("werift");
166
+ await this.pc.addIceCandidate(
167
+ new werift.RTCIceCandidate({ candidate, sdpMid: sdpMid ?? undefined, sdpMLineIndex: sdpMLineIndex ?? undefined }),
168
+ );
169
+ } catch (err) {
170
+ process.stderr.write(`[screen-share] failed to add ICE candidate: ${err}\n`);
171
+ }
172
+ }
173
+
174
+ stop(): void {
175
+ this.active = false;
176
+ if (this.ffmpeg) {
177
+ this.ffmpeg.kill("SIGTERM");
178
+ this.ffmpeg = undefined;
179
+ }
180
+ if (this.pc) {
181
+ try { this.pc.close(); } catch {}
182
+ this.pc = undefined;
183
+ }
184
+ this.options.onStatus(
185
+ createEnvelope({
186
+ type: "screen.status",
187
+ sessionId: this.options.sessionId,
188
+ payload: { active: false, mode: "off" as const },
189
+ }),
190
+ );
191
+ }
192
+
193
+ private startFfmpeg(videoTrack: any): void {
194
+ const os = platform();
195
+ const fps = this.options.fps;
196
+ const scale = this.options.scale;
197
+
198
+ let inputArgs: string[];
199
+ if (os === "darwin") {
200
+ // macOS: AVFoundation screen capture
201
+ inputArgs = ["-f", "avfoundation", "-framerate", String(fps), "-i", "1:none"];
202
+ } else if (os === "linux") {
203
+ // Linux: X11 screen grab
204
+ inputArgs = ["-f", "x11grab", "-framerate", String(fps), "-i", ":0"];
205
+ } else {
206
+ // Windows: GDI screen grab
207
+ inputArgs = ["-f", "gdigrab", "-framerate", String(fps), "-i", "desktop"];
208
+ }
209
+
210
+ const scaleFilter = scale < 1 ? ["-vf", `scale=iw*${scale}:ih*${scale}`] : [];
211
+
212
+ this.ffmpeg = spawn("ffmpeg", [
213
+ ...inputArgs,
214
+ ...scaleFilter,
215
+ "-c:v", "libx264",
216
+ "-preset", "ultrafast",
217
+ "-tune", "zerolatency",
218
+ "-profile:v", "baseline",
219
+ "-level", "3.1",
220
+ "-pix_fmt", "yuv420p",
221
+ "-g", String(fps * 2), // keyframe every 2 seconds
222
+ "-b:v", "1500k",
223
+ "-maxrate", "2000k",
224
+ "-bufsize", "4000k",
225
+ "-f", "h264",
226
+ "-an", // no audio
227
+ "pipe:1",
228
+ ], {
229
+ stdio: ["pipe", "pipe", "pipe"],
230
+ });
231
+
232
+ // Read H.264 NAL units from stdout and push to video track
233
+ let nalBuffer = Buffer.alloc(0);
234
+
235
+ this.ffmpeg.stdout?.on("data", (chunk: Buffer) => {
236
+ if (!this.active) return;
237
+
238
+ nalBuffer = Buffer.concat([nalBuffer, chunk]);
239
+
240
+ // Split on NAL unit start codes (0x00000001 or 0x000001)
241
+ let offset = 0;
242
+ while (offset < nalBuffer.length - 4) {
243
+ let startCodeLen = 0;
244
+ if (nalBuffer[offset] === 0 && nalBuffer[offset + 1] === 0) {
245
+ if (nalBuffer[offset + 2] === 0 && nalBuffer[offset + 3] === 1) {
246
+ startCodeLen = 4;
247
+ } else if (nalBuffer[offset + 2] === 1) {
248
+ startCodeLen = 3;
249
+ }
250
+ }
251
+
252
+ if (startCodeLen > 0 && offset > 0) {
253
+ // Found a NAL unit boundary
254
+ const nalUnit = nalBuffer.subarray(0, offset);
255
+ try {
256
+ videoTrack.writeRtp(nalUnit);
257
+ } catch {
258
+ // Track might not be ready yet
259
+ }
260
+ nalBuffer = nalBuffer.subarray(offset);
261
+ offset = 0;
262
+ continue;
263
+ }
264
+ offset++;
265
+ }
266
+ });
267
+
268
+ this.ffmpeg.stderr?.on("data", (data: Buffer) => {
269
+ // ffmpeg logs to stderr, ignore unless debugging
270
+ const msg = data.toString();
271
+ if (msg.includes("Error") || msg.includes("error")) {
272
+ process.stderr.write(`[screen-share:ffmpeg] ${msg}\n`);
273
+ }
274
+ });
275
+
276
+ this.ffmpeg.on("exit", (code) => {
277
+ if (this.active) {
278
+ process.stderr.write(`[screen-share] ffmpeg exited with code ${code}\n`);
279
+ this.stop();
280
+ }
281
+ });
282
+ }
283
+ }
@@ -0,0 +1,23 @@
1
+ declare module "werift" {
2
+ export class RTCPeerConnection {
3
+ constructor(config?: any);
4
+ onIceCandidate: { subscribe: (cb: (candidate: any) => void) => void };
5
+ connectionStateChange: { subscribe: (cb: (state: string) => void) => void };
6
+ addTrack(track: MediaStreamTrack): void;
7
+ createOffer(): Promise<{ sdp: string; type: string }>;
8
+ setLocalDescription(desc: any): Promise<void>;
9
+ setRemoteDescription(desc: any): Promise<void>;
10
+ addIceCandidate(candidate: any): Promise<void>;
11
+ close(): void;
12
+ }
13
+ export class RTCSessionDescription {
14
+ constructor(sdp: string, type: string);
15
+ }
16
+ export class RTCIceCandidate {
17
+ constructor(init: { candidate: string; sdpMid?: string; sdpMLineIndex?: number });
18
+ }
19
+ export class MediaStreamTrack {
20
+ constructor(init: { kind: string });
21
+ writeRtp(data: Buffer): void;
22
+ }
23
+ }