@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 +1 -1
- package/dist/stream.d.ts +8 -0
- package/dist/stream.js +90 -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. 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 =
|
|
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,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((
|
|
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
|
-
|
|
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
|
-
//
|
|
239
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
},
|