@nativescript/cloudflare-desktop 0.0.0-stage → 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/dist/stream.js ADDED
@@ -0,0 +1,348 @@
1
+ import v8 from 'node:v8';
2
+ import vm from 'node:vm';
3
+ import { FRAME_CHUNK_BYTES, FRAME_END, FRAME_MORE } from '@nativescript/cloudflare-protocol';
4
+ import { InputInjector } from './input.js';
5
+ const FPS = 30;
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 };
16
+ /** Unsent bytes past which frames are dropped rather than queued behind a slow link. */
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;
21
+ const MAX_BACKOFF_MS = 8000;
22
+ /** A viewer reloading the IDE or switching panes is back within this; capture keeps running for it. */
23
+ const STOP_DELAY_MS = 4000;
24
+ /**
25
+ * A wrapper keeps its native object until V8 collects it, and ScreenCaptureKit
26
+ * stops once its few surfaces are all held that way. Runtimes with
27
+ * `interop.releaseNativeObject` let each frame's objects go as soon as it's
28
+ * sent; older ones need a collection every few frames.
29
+ */
30
+ v8.setFlagsFromString('--expose-gc');
31
+ const collect = vm.runInNewContext('gc');
32
+ function releaseAll(objects, frame) {
33
+ const release = interop.releaseNativeObject;
34
+ if (typeof release === 'function') {
35
+ for (const o of objects)
36
+ if (o)
37
+ release(o);
38
+ }
39
+ else if (frame % 4 === 0) {
40
+ collect();
41
+ }
42
+ }
43
+ /** The window a project shows: AppKit's main window, else the frontmost visible one. */
44
+ export function projectWindow() {
45
+ const main = NSApp.mainWindow ?? NSApp.keyWindow;
46
+ if (main?.visible)
47
+ return main;
48
+ const ordered = NSApp.orderedWindows;
49
+ for (let i = 0; i < ordered.count; i++) {
50
+ const w = ordered.objectAtIndex(i);
51
+ if (w.visible)
52
+ return w;
53
+ }
54
+ return null;
55
+ }
56
+ let FrameOutput;
57
+ function frameOutputClass() {
58
+ // Defined lazily: NSObject and SCStreamOutput exist only once the runtime is loaded.
59
+ FrameOutput ??= class FrameOutput extends NSObject {
60
+ static ObjCProtocols = [SCStreamOutput];
61
+ static {
62
+ NativeClass(this);
63
+ }
64
+ onFrame = () => { };
65
+ streamDidOutputSampleBufferOfType(_stream, sampleBuffer, type) {
66
+ if (type === SCStreamOutputType.Screen)
67
+ this.onFrame(sampleBuffer);
68
+ }
69
+ };
70
+ return FrameOutput;
71
+ }
72
+ /**
73
+ * Streams the project's window to the stream relay while anyone watches,
74
+ * and injects the input viewers send back.
75
+ */
76
+ export class WindowStreamer {
77
+ url;
78
+ onState;
79
+ ws;
80
+ viewers = 0;
81
+ capture;
82
+ starting = false;
83
+ ci;
84
+ input = new InputInjector();
85
+ frames = 0;
86
+ quality = QUALITY.max;
87
+ budget = BYTES_PER_SECOND;
88
+ budgetAt = 0;
89
+ framed = false;
90
+ backoff = 500;
91
+ closed = false;
92
+ timer;
93
+ stopTimer;
94
+ constructor(url, onState) {
95
+ this.url = url;
96
+ this.onState = onState;
97
+ }
98
+ open() {
99
+ this.closed = false;
100
+ const ws = new WebSocket(this.url);
101
+ ws.binaryType = 'arraybuffer';
102
+ this.ws = ws;
103
+ ws.onopen = () => (this.backoff = 500);
104
+ ws.onmessage = (e) => {
105
+ if (typeof e.data !== 'string')
106
+ return;
107
+ let msg;
108
+ try {
109
+ msg = JSON.parse(e.data);
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ if (msg.type === 'viewers') {
115
+ this.viewers = msg.count;
116
+ void this.sync();
117
+ }
118
+ else {
119
+ try {
120
+ this.input.inject(msg, this.capture?.window ?? projectWindow());
121
+ }
122
+ catch (err) {
123
+ console.error('[ns-edge desktop] input failed:', err);
124
+ }
125
+ }
126
+ };
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)`);
130
+ if (this.ws !== ws)
131
+ return;
132
+ this.viewers = 0;
133
+ void this.sync();
134
+ if (this.closed)
135
+ return;
136
+ this.timer = setTimeout(() => this.open(), this.backoff);
137
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
138
+ };
139
+ }
140
+ close() {
141
+ this.closed = true;
142
+ clearTimeout(this.timer);
143
+ clearTimeout(this.stopTimer);
144
+ this.ws?.close();
145
+ this.ws = undefined;
146
+ this.stop();
147
+ }
148
+ /** Call after each render: a new version may have replaced the window being captured. */
149
+ retarget() {
150
+ if (this.capture && this.capture.window !== projectWindow())
151
+ this.stop();
152
+ void this.sync();
153
+ }
154
+ async sync() {
155
+ if (this.viewers > 0) {
156
+ clearTimeout(this.stopTimer);
157
+ this.stopTimer = undefined;
158
+ if (!this.capture && !this.starting)
159
+ await this.start();
160
+ }
161
+ else if (this.capture && !this.stopTimer) {
162
+ this.stopTimer = setTimeout(() => {
163
+ this.stopTimer = undefined;
164
+ if (this.viewers === 0)
165
+ this.stop();
166
+ }, STOP_DELAY_MS);
167
+ }
168
+ }
169
+ async start(attempt = 1) {
170
+ const window = projectWindow();
171
+ if (!window)
172
+ return;
173
+ this.starting = true;
174
+ let retry = false;
175
+ try {
176
+ const target = await ownWindow(window.windowNumber);
177
+ if (!target || this.viewers === 0)
178
+ return;
179
+ const filter = SCContentFilter.alloc().initWithDesktopIndependentWindow(target);
180
+ const config = SCStreamConfiguration.new();
181
+ applySize(config, window);
182
+ config.minimumFrameInterval = CMTimeMake(1, FPS);
183
+ config.showsCursor = false;
184
+ config.ignoreShadowsSingleWindow = true;
185
+ const stream = SCStream.alloc().initWithFilterConfigurationDelegate(filter, config, null);
186
+ const output = frameOutputClass().new();
187
+ output.onFrame = (sampleBuffer) => this.frame(sampleBuffer);
188
+ // JS-implemented methods must run on the JS thread, which here is the main thread.
189
+ const ok = stream.addStreamOutputTypeSampleHandlerQueueError(output, SCStreamOutputType.Screen, NSOperationQueue.mainQueue.underlyingQueue, new interop.Reference());
190
+ if (!ok)
191
+ throw new Error('ScreenCaptureKit refused the stream output');
192
+ // Set before starting: the first frame, the only one a still window produces, can arrive before the start callback.
193
+ this.capture = { stream, output, window, size: '' };
194
+ this.framed = false;
195
+ this.sendSize();
196
+ const started = new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve(true))));
197
+ let completed;
198
+ try {
199
+ completed = await Promise.race([started, new Promise((r) => setTimeout(() => r(false), START_TIMEOUT_MS))]);
200
+ }
201
+ catch (err) {
202
+ if (this.capture?.stream === stream)
203
+ this.capture = undefined;
204
+ throw err;
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
+ }
215
+ this.onState?.(true, this.viewers);
216
+ }
217
+ catch (err) {
218
+ console.error('[ns-edge desktop] window capture failed:', err.message);
219
+ }
220
+ finally {
221
+ this.starting = false;
222
+ if (retry && this.viewers > 0)
223
+ await this.start(attempt + 1);
224
+ }
225
+ }
226
+ stop() {
227
+ const capture = this.capture;
228
+ this.capture = undefined;
229
+ if (!capture)
230
+ return;
231
+ capture.stream.stopCaptureWithCompletionHandler(null);
232
+ this.onState?.(false, this.viewers);
233
+ }
234
+ frame(sampleBuffer) {
235
+ const held = [sampleBuffer];
236
+ try {
237
+ this.encode(sampleBuffer, held);
238
+ }
239
+ finally {
240
+ releaseAll(held, ++this.frames);
241
+ }
242
+ }
243
+ /** Objects created along the way go into `held`, released once the frame is sent. */
244
+ encode(sampleBuffer, held) {
245
+ const capture = this.capture;
246
+ const ws = this.ws;
247
+ if (!capture || !ws || ws.readyState !== WebSocket.OPEN)
248
+ return;
249
+ if (this.sendSize()) {
250
+ const config = SCStreamConfiguration.new();
251
+ applySize(config, capture.window);
252
+ config.minimumFrameInterval = CMTimeMake(1, FPS);
253
+ config.showsCursor = false;
254
+ config.ignoreShadowsSingleWindow = true;
255
+ capture.stream.updateConfigurationCompletionHandler(config, null);
256
+ }
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)
261
+ return;
262
+ const pixels = CMSampleBufferGetImageBuffer(sampleBuffer);
263
+ // Frames where nothing changed carry no image.
264
+ if (!pixels)
265
+ return;
266
+ this.framed = true;
267
+ held.push(pixels);
268
+ this.ci ??= CIContext.context();
269
+ const image = CIImage.imageWithCVPixelBuffer(pixels);
270
+ held.push(image);
271
+ const jpeg = this.ci.JPEGRepresentationOfImageColorSpaceOptions(image, CGColorSpaceCreateDeviceRGB(), this.jpegOptions());
272
+ if (!jpeg)
273
+ return;
274
+ held.push(jpeg);
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
+ }
298
+ }
299
+ options;
300
+ jpegOptions() {
301
+ return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(this.quality), kCGImageDestinationLossyCompressionQuality));
302
+ }
303
+ /** Tells viewers the window's size and title when either changed; true if the size did. */
304
+ sendSize() {
305
+ const capture = this.capture;
306
+ if (!capture || this.ws?.readyState !== WebSocket.OPEN)
307
+ return false;
308
+ const { width, height } = capture.window.frame.size;
309
+ const title = String(capture.window.title ?? '');
310
+ const size = `${Math.round(width)}x${Math.round(height)}`;
311
+ const key = `${size}|${title}`;
312
+ if (key === capture.size)
313
+ return false;
314
+ const resized = capture.size !== '' && !capture.size.startsWith(`${size}|`);
315
+ capture.size = key;
316
+ const msg = { type: 'size', width: Math.round(width), height: Math.round(height), title };
317
+ this.ws.send(JSON.stringify(msg));
318
+ return resized;
319
+ }
320
+ }
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
+ */
326
+ function applySize(config, window) {
327
+ const { width, height } = window.frame.size;
328
+ const scale = Math.min(1, MAX_PIXEL_WIDTH / Math.max(width, 1));
329
+ config.width = Math.max(2, Math.round(width * scale));
330
+ config.height = Math.max(2, Math.round(height * scale));
331
+ }
332
+ /**
333
+ * This process's own windows as ScreenCaptureKit sees them. Only the
334
+ * current-process listing (macOS 14.4+) includes them: a bare `node` has no
335
+ * bundle and never appears in the general shareable-content list.
336
+ */
337
+ export function ownWindow(windowNumber) {
338
+ return new Promise((resolve, reject) => SCShareableContent.getCurrentProcessShareableContentWithCompletionHandler((content, error) => {
339
+ if (error)
340
+ return reject(new Error(error.localizedDescription));
341
+ for (let i = 0; i < content.windows.count; i++) {
342
+ const w = content.windows.objectAtIndex(i);
343
+ if (w.windowID === windowNumber)
344
+ return resolve(w);
345
+ }
346
+ resolve(null);
347
+ }));
348
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "sdkVersion": "27.0",
3
+ "target": "macOS 26.0",
4
+ "generatedAt": "2026-10-30T21:20:34.155Z",
5
+ "counts": {
6
+ "strings": 154573,
7
+ "consts": 45464,
8
+ "enums": 3177,
9
+ "signatures": 22910,
10
+ "functions": 18130,
11
+ "protocols": 1057,
12
+ "structs": 4589,
13
+ "classes": 5085,
14
+ "unions": 254
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@nativescript/cloudflare-desktop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.1",
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
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "ns-edge-desktop": "./dist/bin.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "engines": {
17
+ "node": ">=22.15"
18
+ },
19
+ "os": [
20
+ "darwin"
21
+ ],
22
+ "cpu": [
23
+ "arm64"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p .",
27
+ "typecheck": "tsc -p . --noEmit",
28
+ "prepack": "npm run metadata && npm run build",
29
+ "metadata": "node scripts/metadata.mjs"
30
+ },
31
+ "dependencies": {
32
+ "@nativescript/cloudflare-protocol": "0.1.0-beta.2",
33
+ "@nativescript/macos-node-api": "0.4.4-next.2026-08-09-31292056208",
34
+ "es-module-lexer": "^3.0.4"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.0",
38
+ "typescript": "^6.0.0"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "metadata"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "tag": "alpha"
47
+ }
48
+ }