@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 +1 -1
- package/dist/stream.d.ts +6 -0
- package/dist/stream.js +73 -11
- package/package.json +2 -2
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 =
|
|
6
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
//
|
|
239
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
},
|