@nativescript/cloudflare-desktop 0.1.0-alpha.0 → 0.1.0-alpha.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/README.md CHANGED
@@ -30,7 +30,7 @@ AppKit holds control targets and delegates weakly. Keep them referenced from Jav
30
30
  - **Code.** The host joins the project's socket as `platform: 'macos'`, walks each new entry's module graph over HTTP and serves it to Node through `module.registerHooks`. Module URLs are versioned, so only changed modules are fetched. Node can't unload old versions; they stay in memory until the host restarts.
31
31
  - **Event loop.** Cocoa events are pumped from Node's timers, so sockets and `fetch` keep working.
32
32
  - **Memory.** Each frame's native objects are released as soon as it's sent (`interop.releaseNativeObject`, NativeScript/runtimes#103); runtimes without it get a garbage collection every few frames instead.
33
- - **Stream.** While a viewer watches, ScreenCaptureKit captures the project's window (only that window, through the current-process listing, macOS 14.4+) at up to 30 fps; frames go out as JPEG over `/p/<id>/stream/<stream>`, a relay separate from the project's hub.
33
+ - **Stream.** While a viewer watches, ScreenCaptureKit captures the project's window (only that window, through the current-process listing, macOS 14.4+) at up to 30 fps and at the window's point size (at most 800 px wide); frames go out as JPEG over `/p/<id>/stream/<stream>`, a relay separate from the project's hub, in chunks of at most 16 KB under a budget of about 4.8 Mbps with adaptive quality. AppKit can repaint a still window many times a second, so a frame identical to the last one sent is skipped. Cloudflare cuts a Durable Object's WebSocket when messages are large or one Mac streams two windows at once, so the IDE only watches a window while its pane is on screen.
34
34
  - **Input.** Pointer, wheel and key events from the viewer are posted to the app's own event queue, so they reach only this app, never move the real cursor and need no Accessibility permission. A press is held until its release arrives, because a control's tracking loop blocks the JS thread in between: drags land on release, and press-and-hold isn't possible yet.
35
35
 
36
36
  ## Publishing
package/dist/stream.d.ts CHANGED
@@ -14,6 +14,12 @@ export declare class WindowStreamer {
14
14
  private ci?;
15
15
  private input;
16
16
  private frames;
17
+ private quality;
18
+ private budget;
19
+ private budgetAt;
20
+ private framed;
21
+ /** The last JPEG sent; repeats are skipped until a viewer joins. */
22
+ private sent?;
17
23
  private backoff;
18
24
  private closed;
19
25
  private timer?;
@@ -29,6 +35,8 @@ export declare class WindowStreamer {
29
35
  private frame;
30
36
  /** Objects created along the way go into `held`, released once the frame is sent. */
31
37
  private encode;
38
+ /** Steers JPEG quality so frames stay near the size the budget allows at full frame rate. */
39
+ private adaptQuality;
32
40
  private options?;
33
41
  private jpegOptions;
34
42
  /** Tells viewers the window's size and title when either changed; true if the size did. */
package/dist/stream.js CHANGED
@@ -1,11 +1,23 @@
1
1
  import v8 from 'node:v8';
2
2
  import vm from 'node:vm';
3
+ import { FRAME_CHUNK_BYTES, FRAME_END, FRAME_MORE } from '@nativescript/cloudflare-protocol';
3
4
  import { InputInjector } from './input.js';
4
5
  const FPS = 30;
5
- const MAX_PIXEL_WIDTH = 1600;
6
- const JPEG_QUALITY = 0.7;
6
+ const MAX_PIXEL_WIDTH = 800;
7
+ /**
8
+ * Outbound budget. Cloudflare cuts a Durable Object's WebSocket (1006) once
9
+ * sustained inbound traffic passes roughly 7 to 11 Mbps, so the stream stays
10
+ * well under that: busy windows get fewer frames, never a dropped connection.
11
+ */
12
+ const BYTES_PER_SECOND = 600_000;
13
+ const TARGET_FRAME_BYTES = BYTES_PER_SECOND / FPS;
14
+ /** JPEG quality adapts within this range to keep frames near the target size. */
15
+ const QUALITY = { min: 0.35, max: 0.6 };
7
16
  /** Unsent bytes past which frames are dropped rather than queued behind a slow link. */
8
17
  const MAX_BUFFERED = 1_500_000;
18
+ /** ScreenCaptureKit occasionally never completes a start; it is retried after this long without a frame. */
19
+ const START_TIMEOUT_MS = 4000;
20
+ const START_ATTEMPTS = 3;
9
21
  const MAX_BACKOFF_MS = 8000;
10
22
  /** A viewer reloading the IDE or switching panes is back within this; capture keeps running for it. */
11
23
  const STOP_DELAY_MS = 4000;
@@ -71,6 +83,12 @@ export class WindowStreamer {
71
83
  ci;
72
84
  input = new InputInjector();
73
85
  frames = 0;
86
+ quality = QUALITY.max;
87
+ budget = BYTES_PER_SECOND;
88
+ budgetAt = 0;
89
+ framed = false;
90
+ /** The last JPEG sent; repeats are skipped until a viewer joins. */
91
+ sent;
74
92
  backoff = 500;
75
93
  closed = false;
76
94
  timer;
@@ -96,6 +114,8 @@ export class WindowStreamer {
96
114
  return;
97
115
  }
98
116
  if (msg.type === 'viewers') {
117
+ if (msg.count > this.viewers)
118
+ this.sent = undefined;
99
119
  this.viewers = msg.count;
100
120
  void this.sync();
101
121
  }
@@ -108,7 +128,9 @@ export class WindowStreamer {
108
128
  }
109
129
  }
110
130
  };
111
- ws.onclose = () => {
131
+ ws.onclose = (e) => {
132
+ if (process.env.NS_EDGE_DEBUG)
133
+ console.error(`[ns-edge desktop] stream socket closed: ${e.code} ${e.reason} (${ws.bufferedAmount} bytes unsent, ${this.frames} frames)`);
112
134
  if (this.ws !== ws)
113
135
  return;
114
136
  this.viewers = 0;
@@ -148,11 +170,12 @@ export class WindowStreamer {
148
170
  }, STOP_DELAY_MS);
149
171
  }
150
172
  }
151
- async start() {
173
+ async start(attempt = 1) {
152
174
  const window = projectWindow();
153
175
  if (!window)
154
176
  return;
155
177
  this.starting = true;
178
+ let retry = false;
156
179
  try {
157
180
  const target = await ownWindow(window.windowNumber);
158
181
  if (!target || this.viewers === 0)
@@ -172,15 +195,27 @@ export class WindowStreamer {
172
195
  throw new Error('ScreenCaptureKit refused the stream output');
173
196
  // Set before starting: the first frame, the only one a still window produces, can arrive before the start callback.
174
197
  this.capture = { stream, output, window, size: '' };
198
+ this.framed = false;
175
199
  this.sendSize();
200
+ const started = new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve(true))));
201
+ let completed;
176
202
  try {
177
- await new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve())));
203
+ completed = await Promise.race([started, new Promise((r) => setTimeout(() => r(false), START_TIMEOUT_MS))]);
178
204
  }
179
205
  catch (err) {
180
206
  if (this.capture?.stream === stream)
181
207
  this.capture = undefined;
182
208
  throw err;
183
209
  }
210
+ if (!completed && !this.framed) {
211
+ if (this.capture?.stream === stream)
212
+ this.capture = undefined;
213
+ stream.stopCaptureWithCompletionHandler(null);
214
+ retry = attempt < START_ATTEMPTS;
215
+ if (!retry)
216
+ throw new Error(`ScreenCaptureKit did not start after ${START_ATTEMPTS} attempts`);
217
+ return;
218
+ }
184
219
  this.onState?.(true, this.viewers);
185
220
  }
186
221
  catch (err) {
@@ -188,6 +223,8 @@ export class WindowStreamer {
188
223
  }
189
224
  finally {
190
225
  this.starting = false;
226
+ if (retry && this.viewers > 0)
227
+ await this.start(attempt + 1);
191
228
  }
192
229
  }
193
230
  stop() {
@@ -221,12 +258,16 @@ export class WindowStreamer {
221
258
  config.ignoreShadowsSingleWindow = true;
222
259
  capture.stream.updateConfigurationCompletionHandler(config, null);
223
260
  }
224
- if (ws.bufferedAmount > MAX_BUFFERED)
261
+ const now = performance.now();
262
+ this.budget = Math.min(BYTES_PER_SECOND, this.budget + ((now - this.budgetAt) * BYTES_PER_SECOND) / 1000);
263
+ this.budgetAt = now;
264
+ if (this.budget < 0 || ws.bufferedAmount > MAX_BUFFERED)
225
265
  return;
226
266
  const pixels = CMSampleBufferGetImageBuffer(sampleBuffer);
227
267
  // Frames where nothing changed carry no image.
228
268
  if (!pixels)
229
269
  return;
270
+ this.framed = true;
230
271
  held.push(pixels);
231
272
  this.ci ??= CIContext.context();
232
273
  const image = CIImage.imageWithCVPixelBuffer(pixels);
@@ -235,12 +276,38 @@ export class WindowStreamer {
235
276
  if (!jpeg)
236
277
  return;
237
278
  held.push(jpeg);
238
- // A copy: the buffer is backed by the NSData, which is released right after.
239
- ws.send(new Uint8Array(interop.bufferFromData(jpeg)).slice());
279
+ // Chunks are copies: the bytes are backed by the NSData, which is released right after.
280
+ const bytes = new Uint8Array(interop.bufferFromData(jpeg));
281
+ // AppKit can repaint a still window many times a second, and ScreenCaptureKit
282
+ // reports each repaint as a complete frame with the whole window dirty.
283
+ if (this.sent && sameBytes(this.sent, bytes))
284
+ return;
285
+ this.sent = bytes;
286
+ for (let at = 0; at < bytes.byteLength; at += FRAME_CHUNK_BYTES) {
287
+ const payload = bytes.subarray(at, at + FRAME_CHUNK_BYTES);
288
+ const chunk = new Uint8Array(payload.byteLength + 1);
289
+ chunk[0] = at + FRAME_CHUNK_BYTES >= bytes.byteLength ? FRAME_END : FRAME_MORE;
290
+ chunk.set(payload, 1);
291
+ ws.send(chunk);
292
+ }
293
+ this.budget -= bytes.byteLength;
294
+ this.adaptQuality(bytes.byteLength);
295
+ }
296
+ /** Steers JPEG quality so frames stay near the size the budget allows at full frame rate. */
297
+ adaptQuality(bytes) {
298
+ const next = bytes > TARGET_FRAME_BYTES * 1.2
299
+ ? Math.max(QUALITY.min, this.quality - 0.05)
300
+ : bytes < TARGET_FRAME_BYTES * 0.6
301
+ ? Math.min(QUALITY.max, this.quality + 0.02)
302
+ : this.quality;
303
+ if (next !== this.quality) {
304
+ this.quality = next;
305
+ this.options = undefined;
306
+ }
240
307
  }
241
308
  options;
242
309
  jpegOptions() {
243
- return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(JPEG_QUALITY), kCGImageDestinationLossyCompressionQuality));
310
+ return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(this.quality), kCGImageDestinationLossyCompressionQuality));
244
311
  }
245
312
  /** Tells viewers the window's size and title when either changed; true if the size did. */
246
313
  sendSize() {
@@ -260,10 +327,22 @@ export class WindowStreamer {
260
327
  return resized;
261
328
  }
262
329
  }
263
- /** Output pixels: the window's backing size, capped so a large Retina window stays a reasonable stream. */
330
+ function sameBytes(a, b) {
331
+ if (a.byteLength !== b.byteLength)
332
+ return false;
333
+ for (let i = 0; i < a.byteLength; i++)
334
+ if (a[i] !== b[i])
335
+ return false;
336
+ return true;
337
+ }
338
+ /**
339
+ * Output pixels: the window's size in points, not its Retina backing size.
340
+ * Viewers show it in a side pane, and 2x frames are four times the bytes,
341
+ * which halves the frame rate on a typical uplink.
342
+ */
264
343
  function applySize(config, window) {
265
344
  const { width, height } = window.frame.size;
266
- const scale = Math.min(window.backingScaleFactor || 1, MAX_PIXEL_WIDTH / Math.max(width, 1));
345
+ const scale = Math.min(1, MAX_PIXEL_WIDTH / Math.max(width, 1));
267
346
  config.width = Math.max(2, Math.round(width * scale));
268
347
  config.height = Math.max(2, Math.round(height * scale));
269
348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativescript/cloudflare-desktop",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Runs an ns-edge project as a macOS desktop app on the NativeScript Node-API runtime, hot-reloaded from the edge and streamed to the browser IDE",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "metadata": "node scripts/metadata.mjs"
30
30
  },
31
31
  "dependencies": {
32
- "@nativescript/cloudflare-protocol": "0.1.0-beta.1",
32
+ "@nativescript/cloudflare-protocol": "0.1.0-beta.2",
33
33
  "@nativescript/macos-node-api": "0.4.4-next.2026-08-09-31292056208",
34
34
  "es-module-lexer": "^3.0.4"
35
35
  },