linkshell-cli 0.1.15 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,8 @@ import {
11
11
  import type { Envelope } from "@linkshell/protocol";
12
12
  import type { ProviderConfig } from "../providers.js";
13
13
  import { ScrollbackBuffer } from "./scrollback.js";
14
+ import { ScreenFallback } from "./screen-fallback.js";
15
+ import { ScreenShare } from "./screen-share.js";
14
16
  import { getLanIp } from "../utils/lan-ip.js";
15
17
 
16
18
  export interface BridgeSessionOptions {
@@ -21,7 +23,9 @@ export interface BridgeSessionOptions {
21
23
  cols: number;
22
24
  rows: number;
23
25
  clientName: string;
26
+ hostname?: string;
24
27
  verbose?: boolean;
28
+ screen?: boolean;
25
29
  providerConfig: ProviderConfig;
26
30
  }
27
31
 
@@ -100,6 +104,8 @@ export class BridgeSession {
100
104
  private sessionId = "";
101
105
  private exited = false;
102
106
  private stopped = false;
107
+ private screenCapture: ScreenFallback | undefined;
108
+ private screenShare: ScreenShare | undefined;
103
109
 
104
110
  constructor(options: BridgeSessionOptions) {
105
111
  this.options = options;
@@ -208,7 +214,7 @@ export class BridgeSession {
208
214
  clientName: this.options.clientName,
209
215
  provider: this.options.providerConfig.provider,
210
216
  protocolVersion: PROTOCOL_VERSION,
211
- hostname: hostname(),
217
+ hostname: this.options.hostname || hostname(),
212
218
  platform: platform(),
213
219
  },
214
220
  }),
@@ -266,6 +272,25 @@ export class BridgeSession {
266
272
  }
267
273
  case "session.heartbeat":
268
274
  break;
275
+ case "screen.start": {
276
+ const p = parseTypedPayload("screen.start", envelope.payload);
277
+ this.startScreenCapture(p.fps, p.quality, p.scale);
278
+ break;
279
+ }
280
+ case "screen.stop": {
281
+ this.stopScreenCapture();
282
+ break;
283
+ }
284
+ case "screen.answer": {
285
+ const p = parseTypedPayload("screen.answer", envelope.payload);
286
+ this.screenShare?.handleAnswer(p.sdp);
287
+ break;
288
+ }
289
+ case "screen.ice": {
290
+ const p = parseTypedPayload("screen.ice", envelope.payload);
291
+ this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
292
+ break;
293
+ }
269
294
  default:
270
295
  break;
271
296
  }
@@ -372,6 +397,68 @@ export class BridgeSession {
372
397
  }
373
398
  }
374
399
 
400
+ private startScreenCapture(fps: number, quality: number, scale: number): void {
401
+ if (!this.options.screen) {
402
+ this.log("screen sharing not enabled (use --screen)");
403
+ this.send(
404
+ createEnvelope({
405
+ type: "screen.status",
406
+ sessionId: this.sessionId,
407
+ payload: { active: false, mode: "off" as const, error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
408
+ }),
409
+ );
410
+ return;
411
+ }
412
+ this.stopScreenCapture();
413
+ this.log(`starting screen capture (fps=${fps}, quality=${quality}, scale=${scale})`);
414
+
415
+ // Try WebRTC first, fall back to screenshot stream
416
+ if (ScreenShare.isAvailable()) {
417
+ this.log("WebRTC available, starting screen share");
418
+ this.screenShare = new ScreenShare({
419
+ sessionId: this.sessionId,
420
+ fps,
421
+ quality,
422
+ scale,
423
+ onSignal: (envelope) => this.send(envelope),
424
+ onStatus: (envelope) => this.send(envelope),
425
+ });
426
+ this.screenShare.start().catch((err) => {
427
+ this.log(`WebRTC failed, falling back to screenshot stream: ${err}`);
428
+ this.screenShare = undefined;
429
+ this.startFallbackCapture(fps, quality, scale);
430
+ });
431
+ } else {
432
+ this.log("WebRTC not available (missing werift or ffmpeg), using screenshot fallback");
433
+ this.startFallbackCapture(fps, quality, scale);
434
+ }
435
+ }
436
+
437
+ private startFallbackCapture(fps: number, quality: number, scale: number): void {
438
+ this.screenCapture = new ScreenFallback({
439
+ fps,
440
+ quality,
441
+ scale,
442
+ sessionId: this.sessionId,
443
+ onFrame: (envelope) => this.send(envelope),
444
+ onStatus: (envelope) => this.send(envelope),
445
+ });
446
+ this.screenCapture.start();
447
+ }
448
+
449
+ private stopScreenCapture(): void {
450
+ if (this.screenShare) {
451
+ this.log("stopping WebRTC screen share");
452
+ this.screenShare.stop();
453
+ this.screenShare = undefined;
454
+ }
455
+ if (this.screenCapture) {
456
+ this.log("stopping screenshot capture");
457
+ this.screenCapture.stop();
458
+ this.screenCapture = undefined;
459
+ }
460
+ }
461
+
375
462
  private scheduleReconnect(): void {
376
463
  if (this.reconnecting || this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
377
464
  process.stderr.write(
@@ -407,6 +494,7 @@ export class BridgeSession {
407
494
  this.stopped = true;
408
495
  this.exited = true;
409
496
  this.stopHeartbeat();
497
+ this.stopScreenCapture();
410
498
  if (this.reconnectTimer) {
411
499
  clearTimeout(this.reconnectTimer);
412
500
  this.reconnectTimer = undefined;
@@ -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
+ }