@nativescript/cloudflare-desktop 0.1.0-alpha.0 → 0.1.0-alpha.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.
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. 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,10 @@ 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;
17
21
  private backoff;
18
22
  private closed;
19
23
  private timer?;
@@ -29,6 +33,8 @@ export declare class WindowStreamer {
29
33
  private frame;
30
34
  /** Objects created along the way go into `held`, released once the frame is sent. */
31
35
  private encode;
36
+ /** Steers JPEG quality so frames stay near the size the budget allows at full frame rate. */
37
+ private adaptQuality;
32
38
  private options?;
33
39
  private jpegOptions;
34
40
  /** 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,10 @@ 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;
74
90
  backoff = 500;
75
91
  closed = false;
76
92
  timer;
@@ -108,7 +124,9 @@ export class WindowStreamer {
108
124
  }
109
125
  }
110
126
  };
111
- ws.onclose = () => {
127
+ ws.onclose = (e) => {
128
+ if (process.env.NS_EDGE_DEBUG)
129
+ console.error(`[ns-edge desktop] stream socket closed: ${e.code} ${e.reason} (${ws.bufferedAmount} bytes unsent, ${this.frames} frames)`);
112
130
  if (this.ws !== ws)
113
131
  return;
114
132
  this.viewers = 0;
@@ -148,11 +166,12 @@ export class WindowStreamer {
148
166
  }, STOP_DELAY_MS);
149
167
  }
150
168
  }
151
- async start() {
169
+ async start(attempt = 1) {
152
170
  const window = projectWindow();
153
171
  if (!window)
154
172
  return;
155
173
  this.starting = true;
174
+ let retry = false;
156
175
  try {
157
176
  const target = await ownWindow(window.windowNumber);
158
177
  if (!target || this.viewers === 0)
@@ -172,15 +191,27 @@ export class WindowStreamer {
172
191
  throw new Error('ScreenCaptureKit refused the stream output');
173
192
  // Set before starting: the first frame, the only one a still window produces, can arrive before the start callback.
174
193
  this.capture = { stream, output, window, size: '' };
194
+ this.framed = false;
175
195
  this.sendSize();
196
+ const started = new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve(true))));
197
+ let completed;
176
198
  try {
177
- await new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve())));
199
+ completed = await Promise.race([started, new Promise((r) => setTimeout(() => r(false), START_TIMEOUT_MS))]);
178
200
  }
179
201
  catch (err) {
180
202
  if (this.capture?.stream === stream)
181
203
  this.capture = undefined;
182
204
  throw err;
183
205
  }
206
+ if (!completed && !this.framed) {
207
+ if (this.capture?.stream === stream)
208
+ this.capture = undefined;
209
+ stream.stopCaptureWithCompletionHandler(null);
210
+ retry = attempt < START_ATTEMPTS;
211
+ if (!retry)
212
+ throw new Error(`ScreenCaptureKit did not start after ${START_ATTEMPTS} attempts`);
213
+ return;
214
+ }
184
215
  this.onState?.(true, this.viewers);
185
216
  }
186
217
  catch (err) {
@@ -188,6 +219,8 @@ export class WindowStreamer {
188
219
  }
189
220
  finally {
190
221
  this.starting = false;
222
+ if (retry && this.viewers > 0)
223
+ await this.start(attempt + 1);
191
224
  }
192
225
  }
193
226
  stop() {
@@ -221,12 +254,16 @@ export class WindowStreamer {
221
254
  config.ignoreShadowsSingleWindow = true;
222
255
  capture.stream.updateConfigurationCompletionHandler(config, null);
223
256
  }
224
- if (ws.bufferedAmount > MAX_BUFFERED)
257
+ const now = performance.now();
258
+ this.budget = Math.min(BYTES_PER_SECOND, this.budget + ((now - this.budgetAt) * BYTES_PER_SECOND) / 1000);
259
+ this.budgetAt = now;
260
+ if (this.budget < 0 || ws.bufferedAmount > MAX_BUFFERED)
225
261
  return;
226
262
  const pixels = CMSampleBufferGetImageBuffer(sampleBuffer);
227
263
  // Frames where nothing changed carry no image.
228
264
  if (!pixels)
229
265
  return;
266
+ this.framed = true;
230
267
  held.push(pixels);
231
268
  this.ci ??= CIContext.context();
232
269
  const image = CIImage.imageWithCVPixelBuffer(pixels);
@@ -235,12 +272,33 @@ export class WindowStreamer {
235
272
  if (!jpeg)
236
273
  return;
237
274
  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());
275
+ // Chunks are copies: the bytes are backed by the NSData, which is released right after.
276
+ const bytes = new Uint8Array(interop.bufferFromData(jpeg));
277
+ for (let at = 0; at < bytes.byteLength; at += FRAME_CHUNK_BYTES) {
278
+ const payload = bytes.subarray(at, at + FRAME_CHUNK_BYTES);
279
+ const chunk = new Uint8Array(payload.byteLength + 1);
280
+ chunk[0] = at + FRAME_CHUNK_BYTES >= bytes.byteLength ? FRAME_END : FRAME_MORE;
281
+ chunk.set(payload, 1);
282
+ ws.send(chunk);
283
+ }
284
+ this.budget -= bytes.byteLength;
285
+ this.adaptQuality(bytes.byteLength);
286
+ }
287
+ /** Steers JPEG quality so frames stay near the size the budget allows at full frame rate. */
288
+ adaptQuality(bytes) {
289
+ const next = bytes > TARGET_FRAME_BYTES * 1.2
290
+ ? Math.max(QUALITY.min, this.quality - 0.05)
291
+ : bytes < TARGET_FRAME_BYTES * 0.6
292
+ ? Math.min(QUALITY.max, this.quality + 0.02)
293
+ : this.quality;
294
+ if (next !== this.quality) {
295
+ this.quality = next;
296
+ this.options = undefined;
297
+ }
240
298
  }
241
299
  options;
242
300
  jpegOptions() {
243
- return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(JPEG_QUALITY), kCGImageDestinationLossyCompressionQuality));
301
+ return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(this.quality), kCGImageDestinationLossyCompressionQuality));
244
302
  }
245
303
  /** Tells viewers the window's size and title when either changed; true if the size did. */
246
304
  sendSize() {
@@ -260,10 +318,14 @@ export class WindowStreamer {
260
318
  return resized;
261
319
  }
262
320
  }
263
- /** Output pixels: the window's backing size, capped so a large Retina window stays a reasonable stream. */
321
+ /**
322
+ * Output pixels: the window's size in points, not its Retina backing size.
323
+ * Viewers show it in a side pane, and 2x frames are four times the bytes,
324
+ * which halves the frame rate on a typical uplink.
325
+ */
264
326
  function applySize(config, window) {
265
327
  const { width, height } = window.frame.size;
266
- const scale = Math.min(window.backingScaleFactor || 1, MAX_PIXEL_WIDTH / Math.max(width, 1));
328
+ const scale = Math.min(1, MAX_PIXEL_WIDTH / Math.max(width, 1));
267
329
  config.width = Math.max(2, Math.round(width * scale));
268
330
  config.height = Math.max(2, Math.round(height * scale));
269
331
  }
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.1",
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
  },