@nativescript/cloudflare-desktop 0.0.0-stage → 0.1.0-alpha.0
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 +41 -2
- package/dist/app.d.ts +5 -0
- package/dist/app.js +44 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +63 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.js +223 -0
- package/dist/electron.d.ts +10 -0
- package/dist/electron.js +18 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/input.d.ts +31 -0
- package/dist/input.js +162 -0
- package/dist/loader.d.ts +3 -0
- package/dist/loader.js +66 -0
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.js +85 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +38 -0
- package/dist/serialize.d.ts +7 -0
- package/dist/serialize.js +42 -0
- package/dist/stream.d.ts +42 -0
- package/dist/stream.js +286 -0
- package/metadata/metadata.json +16 -0
- package/metadata/metadata.macos.arm64.nsmd +0 -0
- package/package.json +46 -4
package/dist/stream.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import v8 from 'node:v8';
|
|
2
|
+
import vm from 'node:vm';
|
|
3
|
+
import { InputInjector } from './input.js';
|
|
4
|
+
const FPS = 30;
|
|
5
|
+
const MAX_PIXEL_WIDTH = 1600;
|
|
6
|
+
const JPEG_QUALITY = 0.7;
|
|
7
|
+
/** Unsent bytes past which frames are dropped rather than queued behind a slow link. */
|
|
8
|
+
const MAX_BUFFERED = 1_500_000;
|
|
9
|
+
const MAX_BACKOFF_MS = 8000;
|
|
10
|
+
/** A viewer reloading the IDE or switching panes is back within this; capture keeps running for it. */
|
|
11
|
+
const STOP_DELAY_MS = 4000;
|
|
12
|
+
/**
|
|
13
|
+
* A wrapper keeps its native object until V8 collects it, and ScreenCaptureKit
|
|
14
|
+
* stops once its few surfaces are all held that way. Runtimes with
|
|
15
|
+
* `interop.releaseNativeObject` let each frame's objects go as soon as it's
|
|
16
|
+
* sent; older ones need a collection every few frames.
|
|
17
|
+
*/
|
|
18
|
+
v8.setFlagsFromString('--expose-gc');
|
|
19
|
+
const collect = vm.runInNewContext('gc');
|
|
20
|
+
function releaseAll(objects, frame) {
|
|
21
|
+
const release = interop.releaseNativeObject;
|
|
22
|
+
if (typeof release === 'function') {
|
|
23
|
+
for (const o of objects)
|
|
24
|
+
if (o)
|
|
25
|
+
release(o);
|
|
26
|
+
}
|
|
27
|
+
else if (frame % 4 === 0) {
|
|
28
|
+
collect();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The window a project shows: AppKit's main window, else the frontmost visible one. */
|
|
32
|
+
export function projectWindow() {
|
|
33
|
+
const main = NSApp.mainWindow ?? NSApp.keyWindow;
|
|
34
|
+
if (main?.visible)
|
|
35
|
+
return main;
|
|
36
|
+
const ordered = NSApp.orderedWindows;
|
|
37
|
+
for (let i = 0; i < ordered.count; i++) {
|
|
38
|
+
const w = ordered.objectAtIndex(i);
|
|
39
|
+
if (w.visible)
|
|
40
|
+
return w;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
let FrameOutput;
|
|
45
|
+
function frameOutputClass() {
|
|
46
|
+
// Defined lazily: NSObject and SCStreamOutput exist only once the runtime is loaded.
|
|
47
|
+
FrameOutput ??= class FrameOutput extends NSObject {
|
|
48
|
+
static ObjCProtocols = [SCStreamOutput];
|
|
49
|
+
static {
|
|
50
|
+
NativeClass(this);
|
|
51
|
+
}
|
|
52
|
+
onFrame = () => { };
|
|
53
|
+
streamDidOutputSampleBufferOfType(_stream, sampleBuffer, type) {
|
|
54
|
+
if (type === SCStreamOutputType.Screen)
|
|
55
|
+
this.onFrame(sampleBuffer);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return FrameOutput;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Streams the project's window to the stream relay while anyone watches,
|
|
62
|
+
* and injects the input viewers send back.
|
|
63
|
+
*/
|
|
64
|
+
export class WindowStreamer {
|
|
65
|
+
url;
|
|
66
|
+
onState;
|
|
67
|
+
ws;
|
|
68
|
+
viewers = 0;
|
|
69
|
+
capture;
|
|
70
|
+
starting = false;
|
|
71
|
+
ci;
|
|
72
|
+
input = new InputInjector();
|
|
73
|
+
frames = 0;
|
|
74
|
+
backoff = 500;
|
|
75
|
+
closed = false;
|
|
76
|
+
timer;
|
|
77
|
+
stopTimer;
|
|
78
|
+
constructor(url, onState) {
|
|
79
|
+
this.url = url;
|
|
80
|
+
this.onState = onState;
|
|
81
|
+
}
|
|
82
|
+
open() {
|
|
83
|
+
this.closed = false;
|
|
84
|
+
const ws = new WebSocket(this.url);
|
|
85
|
+
ws.binaryType = 'arraybuffer';
|
|
86
|
+
this.ws = ws;
|
|
87
|
+
ws.onopen = () => (this.backoff = 500);
|
|
88
|
+
ws.onmessage = (e) => {
|
|
89
|
+
if (typeof e.data !== 'string')
|
|
90
|
+
return;
|
|
91
|
+
let msg;
|
|
92
|
+
try {
|
|
93
|
+
msg = JSON.parse(e.data);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (msg.type === 'viewers') {
|
|
99
|
+
this.viewers = msg.count;
|
|
100
|
+
void this.sync();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
try {
|
|
104
|
+
this.input.inject(msg, this.capture?.window ?? projectWindow());
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.error('[ns-edge desktop] input failed:', err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
ws.onclose = () => {
|
|
112
|
+
if (this.ws !== ws)
|
|
113
|
+
return;
|
|
114
|
+
this.viewers = 0;
|
|
115
|
+
void this.sync();
|
|
116
|
+
if (this.closed)
|
|
117
|
+
return;
|
|
118
|
+
this.timer = setTimeout(() => this.open(), this.backoff);
|
|
119
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
close() {
|
|
123
|
+
this.closed = true;
|
|
124
|
+
clearTimeout(this.timer);
|
|
125
|
+
clearTimeout(this.stopTimer);
|
|
126
|
+
this.ws?.close();
|
|
127
|
+
this.ws = undefined;
|
|
128
|
+
this.stop();
|
|
129
|
+
}
|
|
130
|
+
/** Call after each render: a new version may have replaced the window being captured. */
|
|
131
|
+
retarget() {
|
|
132
|
+
if (this.capture && this.capture.window !== projectWindow())
|
|
133
|
+
this.stop();
|
|
134
|
+
void this.sync();
|
|
135
|
+
}
|
|
136
|
+
async sync() {
|
|
137
|
+
if (this.viewers > 0) {
|
|
138
|
+
clearTimeout(this.stopTimer);
|
|
139
|
+
this.stopTimer = undefined;
|
|
140
|
+
if (!this.capture && !this.starting)
|
|
141
|
+
await this.start();
|
|
142
|
+
}
|
|
143
|
+
else if (this.capture && !this.stopTimer) {
|
|
144
|
+
this.stopTimer = setTimeout(() => {
|
|
145
|
+
this.stopTimer = undefined;
|
|
146
|
+
if (this.viewers === 0)
|
|
147
|
+
this.stop();
|
|
148
|
+
}, STOP_DELAY_MS);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async start() {
|
|
152
|
+
const window = projectWindow();
|
|
153
|
+
if (!window)
|
|
154
|
+
return;
|
|
155
|
+
this.starting = true;
|
|
156
|
+
try {
|
|
157
|
+
const target = await ownWindow(window.windowNumber);
|
|
158
|
+
if (!target || this.viewers === 0)
|
|
159
|
+
return;
|
|
160
|
+
const filter = SCContentFilter.alloc().initWithDesktopIndependentWindow(target);
|
|
161
|
+
const config = SCStreamConfiguration.new();
|
|
162
|
+
applySize(config, window);
|
|
163
|
+
config.minimumFrameInterval = CMTimeMake(1, FPS);
|
|
164
|
+
config.showsCursor = false;
|
|
165
|
+
config.ignoreShadowsSingleWindow = true;
|
|
166
|
+
const stream = SCStream.alloc().initWithFilterConfigurationDelegate(filter, config, null);
|
|
167
|
+
const output = frameOutputClass().new();
|
|
168
|
+
output.onFrame = (sampleBuffer) => this.frame(sampleBuffer);
|
|
169
|
+
// JS-implemented methods must run on the JS thread, which here is the main thread.
|
|
170
|
+
const ok = stream.addStreamOutputTypeSampleHandlerQueueError(output, SCStreamOutputType.Screen, NSOperationQueue.mainQueue.underlyingQueue, new interop.Reference());
|
|
171
|
+
if (!ok)
|
|
172
|
+
throw new Error('ScreenCaptureKit refused the stream output');
|
|
173
|
+
// Set before starting: the first frame, the only one a still window produces, can arrive before the start callback.
|
|
174
|
+
this.capture = { stream, output, window, size: '' };
|
|
175
|
+
this.sendSize();
|
|
176
|
+
try {
|
|
177
|
+
await new Promise((resolve, reject) => stream.startCaptureWithCompletionHandler((err) => (err ? reject(new Error(err.localizedDescription)) : resolve())));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (this.capture?.stream === stream)
|
|
181
|
+
this.capture = undefined;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
this.onState?.(true, this.viewers);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.error('[ns-edge desktop] window capture failed:', err.message);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
this.starting = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
stop() {
|
|
194
|
+
const capture = this.capture;
|
|
195
|
+
this.capture = undefined;
|
|
196
|
+
if (!capture)
|
|
197
|
+
return;
|
|
198
|
+
capture.stream.stopCaptureWithCompletionHandler(null);
|
|
199
|
+
this.onState?.(false, this.viewers);
|
|
200
|
+
}
|
|
201
|
+
frame(sampleBuffer) {
|
|
202
|
+
const held = [sampleBuffer];
|
|
203
|
+
try {
|
|
204
|
+
this.encode(sampleBuffer, held);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
releaseAll(held, ++this.frames);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Objects created along the way go into `held`, released once the frame is sent. */
|
|
211
|
+
encode(sampleBuffer, held) {
|
|
212
|
+
const capture = this.capture;
|
|
213
|
+
const ws = this.ws;
|
|
214
|
+
if (!capture || !ws || ws.readyState !== WebSocket.OPEN)
|
|
215
|
+
return;
|
|
216
|
+
if (this.sendSize()) {
|
|
217
|
+
const config = SCStreamConfiguration.new();
|
|
218
|
+
applySize(config, capture.window);
|
|
219
|
+
config.minimumFrameInterval = CMTimeMake(1, FPS);
|
|
220
|
+
config.showsCursor = false;
|
|
221
|
+
config.ignoreShadowsSingleWindow = true;
|
|
222
|
+
capture.stream.updateConfigurationCompletionHandler(config, null);
|
|
223
|
+
}
|
|
224
|
+
if (ws.bufferedAmount > MAX_BUFFERED)
|
|
225
|
+
return;
|
|
226
|
+
const pixels = CMSampleBufferGetImageBuffer(sampleBuffer);
|
|
227
|
+
// Frames where nothing changed carry no image.
|
|
228
|
+
if (!pixels)
|
|
229
|
+
return;
|
|
230
|
+
held.push(pixels);
|
|
231
|
+
this.ci ??= CIContext.context();
|
|
232
|
+
const image = CIImage.imageWithCVPixelBuffer(pixels);
|
|
233
|
+
held.push(image);
|
|
234
|
+
const jpeg = this.ci.JPEGRepresentationOfImageColorSpaceOptions(image, CGColorSpaceCreateDeviceRGB(), this.jpegOptions());
|
|
235
|
+
if (!jpeg)
|
|
236
|
+
return;
|
|
237
|
+
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());
|
|
240
|
+
}
|
|
241
|
+
options;
|
|
242
|
+
jpegOptions() {
|
|
243
|
+
return (this.options ??= NSDictionary.dictionaryWithObjectForKey(NSNumber.numberWithDouble(JPEG_QUALITY), kCGImageDestinationLossyCompressionQuality));
|
|
244
|
+
}
|
|
245
|
+
/** Tells viewers the window's size and title when either changed; true if the size did. */
|
|
246
|
+
sendSize() {
|
|
247
|
+
const capture = this.capture;
|
|
248
|
+
if (!capture || this.ws?.readyState !== WebSocket.OPEN)
|
|
249
|
+
return false;
|
|
250
|
+
const { width, height } = capture.window.frame.size;
|
|
251
|
+
const title = String(capture.window.title ?? '');
|
|
252
|
+
const size = `${Math.round(width)}x${Math.round(height)}`;
|
|
253
|
+
const key = `${size}|${title}`;
|
|
254
|
+
if (key === capture.size)
|
|
255
|
+
return false;
|
|
256
|
+
const resized = capture.size !== '' && !capture.size.startsWith(`${size}|`);
|
|
257
|
+
capture.size = key;
|
|
258
|
+
const msg = { type: 'size', width: Math.round(width), height: Math.round(height), title };
|
|
259
|
+
this.ws.send(JSON.stringify(msg));
|
|
260
|
+
return resized;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Output pixels: the window's backing size, capped so a large Retina window stays a reasonable stream. */
|
|
264
|
+
function applySize(config, window) {
|
|
265
|
+
const { width, height } = window.frame.size;
|
|
266
|
+
const scale = Math.min(window.backingScaleFactor || 1, MAX_PIXEL_WIDTH / Math.max(width, 1));
|
|
267
|
+
config.width = Math.max(2, Math.round(width * scale));
|
|
268
|
+
config.height = Math.max(2, Math.round(height * scale));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* This process's own windows as ScreenCaptureKit sees them. Only the
|
|
272
|
+
* current-process listing (macOS 14.4+) includes them: a bare `node` has no
|
|
273
|
+
* bundle and never appears in the general shareable-content list.
|
|
274
|
+
*/
|
|
275
|
+
export function ownWindow(windowNumber) {
|
|
276
|
+
return new Promise((resolve, reject) => SCShareableContent.getCurrentProcessShareableContentWithCompletionHandler((content, error) => {
|
|
277
|
+
if (error)
|
|
278
|
+
return reject(new Error(error.localizedDescription));
|
|
279
|
+
for (let i = 0; i < content.windows.count; i++) {
|
|
280
|
+
const w = content.windows.objectAtIndex(i);
|
|
281
|
+
if (w.windowID === windowNumber)
|
|
282
|
+
return resolve(w);
|
|
283
|
+
}
|
|
284
|
+
resolve(null);
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
@@ -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
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nativescript/cloudflare-desktop",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
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.1",
|
|
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
|
+
}
|