@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/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 +48 -0
- package/dist/stream.js +348 -0
- package/metadata/metadata.json +16 -0
- package/metadata/metadata.macos.arm64.nsmd +0 -0
- package/package.json +46 -4
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nativescript/cloudflare-desktop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Runs an ns-edge project as a macOS desktop app on the [NativeScript Node-API runtime](https://github.com/NativeScript/runtimes/tree/main/packages/macos-node-api), hot-reloaded from the edge, with its window streamed to the project's browser IDE.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @nativescript/cloudflare-desktop 'https://<host>/p/<project id>#<device key>'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The device link is in the IDE's **Pair device** dialog. Create the project from the `macos` template (`POST /api/projects?template=macos`). Projects from the `electron` template run in the Electron host instead: see [apps/desktop-electron](../../apps/desktop-electron/README.md).
|
|
10
|
+
|
|
11
|
+
The host asks before joining: the project's code runs in this process with your user account's access to files, network and apps, and anyone with the write key can watch and click its window.
|
|
12
|
+
|
|
13
|
+
| Option | |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `--yes` | Join without asking |
|
|
16
|
+
| `--allow-eval` | Let the agent's `runOnDevice` run code here (off by default) |
|
|
17
|
+
| `--metadata <path>` | Runtime metadata (.nsmd) to use instead of the copy bundled in the package. The npm runtime's built-in metadata has no `SCStream`, which streaming needs |
|
|
18
|
+
| `--runtime <path>` | A `NativeScript.framework` binary to load instead of the npm one |
|
|
19
|
+
|
|
20
|
+
`NS_METADATA` and `NS_RUNTIME` set the same defaults.
|
|
21
|
+
|
|
22
|
+
## Entry contract
|
|
23
|
+
|
|
24
|
+
A desktop app owns windows rather than one root view. `app/main.ts` default-exports a function that opens the app and returns the function that closes it; the host calls that before running the next version. If it returns nothing, the host closes the windows it opened.
|
|
25
|
+
|
|
26
|
+
AppKit holds control targets and delegates weakly. Keep them referenced from JavaScript for as long as their views live, for example from the returned close function, or they're collected and the controls stop responding.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
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
|
+
- **Event loop.** Cocoa events are pumped from Node's timers, so sockets and `fetch` keep working.
|
|
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 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
|
+
- **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
|
+
|
|
36
|
+
## Publishing
|
|
37
|
+
|
|
38
|
+
`npm pack` runs `scripts/metadata.mjs` first, which copies runtime metadata generated from a macOS SDK (`NS_METADATA`, default `../ns-electron/metadata`) into `metadata/`, so the published host streams without the user generating any.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
Apple Silicon, macOS 14.4 or later, Node 22.15 or later.
|
package/dist/app.d.ts
ADDED
package/dist/app.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const PUMP_MS = 4;
|
|
2
|
+
/**
|
|
3
|
+
* Runs Cocoa's event loop from Node's, so Node keeps its timers and sockets
|
|
4
|
+
* and AppKit still gets every event, including ones the host posts itself.
|
|
5
|
+
*/
|
|
6
|
+
export function startApp(name) {
|
|
7
|
+
const app = NSApplication.sharedApplication;
|
|
8
|
+
app.setActivationPolicy(NSApplicationActivationPolicy.Regular);
|
|
9
|
+
app.mainMenu = mainMenu(name);
|
|
10
|
+
app.finishLaunching();
|
|
11
|
+
const pump = () => {
|
|
12
|
+
for (let i = 0; i < 64; i++) {
|
|
13
|
+
const event = app.nextEventMatchingMaskUntilDateInModeDequeue(NSEventMask.Any, null, 'kCFRunLoopDefaultMode', true);
|
|
14
|
+
if (!event)
|
|
15
|
+
break;
|
|
16
|
+
app.sendEvent(event);
|
|
17
|
+
}
|
|
18
|
+
setTimeout(pump, PUMP_MS);
|
|
19
|
+
};
|
|
20
|
+
pump();
|
|
21
|
+
}
|
|
22
|
+
/** Quit and the standard Edit commands; text fields only get ⌘C/⌘V/⌘A through menu key equivalents. */
|
|
23
|
+
function mainMenu(name) {
|
|
24
|
+
const bar = NSMenu.new();
|
|
25
|
+
const appMenu = NSMenu.alloc().initWithTitle(name);
|
|
26
|
+
appMenu.addItemWithTitleActionKeyEquivalent(`Quit ${name}`, 'terminate:', 'q');
|
|
27
|
+
const edit = NSMenu.alloc().initWithTitle('Edit');
|
|
28
|
+
for (const [title, action, key] of [
|
|
29
|
+
['Undo', 'undo:', 'z'],
|
|
30
|
+
['Redo', 'redo:', 'Z'],
|
|
31
|
+
['Cut', 'cut:', 'x'],
|
|
32
|
+
['Copy', 'copy:', 'c'],
|
|
33
|
+
['Paste', 'paste:', 'v'],
|
|
34
|
+
['Select All', 'selectAll:', 'a'],
|
|
35
|
+
]) {
|
|
36
|
+
edit.addItemWithTitleActionKeyEquivalent(title, action, key);
|
|
37
|
+
}
|
|
38
|
+
for (const menu of [appMenu, edit]) {
|
|
39
|
+
const item = NSMenuItem.new();
|
|
40
|
+
item.submenu = menu;
|
|
41
|
+
bar.addItem(item);
|
|
42
|
+
}
|
|
43
|
+
return bar;
|
|
44
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { startApp } from './app.js';
|
|
5
|
+
import { DesktopClient } from './client.js';
|
|
6
|
+
import { loadRuntime } from './runtime.js';
|
|
7
|
+
const USAGE = `Usage: ns-edge-desktop <device link> [options]
|
|
8
|
+
|
|
9
|
+
Runs an ns-edge project on this Mac and streams its window to the project's IDE.
|
|
10
|
+
The device link is the project's https://<host>/p/<id>#<device key> link.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--yes Join without asking
|
|
14
|
+
--allow-eval Let the project's agent run arbitrary code here (runOnDevice)
|
|
15
|
+
--metadata <p> Runtime metadata (.nsmd) generated from this Mac's SDK
|
|
16
|
+
--runtime <p> A NativeScript.framework binary to load instead of the npm one
|
|
17
|
+
`;
|
|
18
|
+
const out = (line) => process.stderr.write(`${line}\n`);
|
|
19
|
+
async function main() {
|
|
20
|
+
const { values, positionals } = parseArgs({
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
yes: { type: 'boolean', default: false },
|
|
24
|
+
'allow-eval': { type: 'boolean', default: false },
|
|
25
|
+
metadata: { type: 'string', default: process.env.NS_METADATA },
|
|
26
|
+
runtime: { type: 'string', default: process.env.NS_RUNTIME },
|
|
27
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const link = positionals[0];
|
|
31
|
+
if (values.help || !link)
|
|
32
|
+
return out(USAGE);
|
|
33
|
+
const { url } = DesktopClient.parse(link);
|
|
34
|
+
out(`Project: ${url}`);
|
|
35
|
+
out('Its code will run in this process with your user account\'s access to files, network and apps,');
|
|
36
|
+
out(`and anyone with the project's write key can watch and click its window${values['allow-eval'] ? ' and run code here' : ''}.`);
|
|
37
|
+
if (!values.yes) {
|
|
38
|
+
if (!process.stdin.isTTY)
|
|
39
|
+
throw new Error('Not a terminal: pass --yes to join without asking.');
|
|
40
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
41
|
+
const answer = await rl.question('Join? [y/N] ');
|
|
42
|
+
rl.close();
|
|
43
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
44
|
+
return out('Not joined.');
|
|
45
|
+
}
|
|
46
|
+
await loadRuntime({ runtime: values.runtime, metadata: values.metadata });
|
|
47
|
+
startApp('ns-edge Desktop');
|
|
48
|
+
const client = new DesktopClient({
|
|
49
|
+
link,
|
|
50
|
+
allowEval: values['allow-eval'],
|
|
51
|
+
onStatus: (status, detail) => out(`[ns-edge] ${status}${detail ? ` ${detail}` : ''}`),
|
|
52
|
+
onRendered: ({ version, ms, fetched }) => out(`[ns-edge] v${version} rendered in ${ms} ms (${fetched} module${fetched === 1 ? '' : 's'} fetched)`),
|
|
53
|
+
onError: (message) => out(`[ns-edge] ${message}`),
|
|
54
|
+
onStream: (streaming, viewers) => out(`[ns-edge] ${streaming ? `streaming the window to ${viewers} viewer${viewers === 1 ? '' : 's'}` : 'stopped streaming'}`),
|
|
55
|
+
});
|
|
56
|
+
client.connect();
|
|
57
|
+
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
58
|
+
process.on(signal, () => (client.close(), process.exit(0)));
|
|
59
|
+
}
|
|
60
|
+
main().catch((e) => {
|
|
61
|
+
out(String(e instanceof Error ? e.message : e));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DeviceInfo } from '@nativescript/cloudflare-protocol';
|
|
2
|
+
export type DesktopStatus = 'connecting' | 'connected' | 'offline';
|
|
3
|
+
export interface DesktopClientOptions {
|
|
4
|
+
/** `http(s)://host/p/<id>#<device key>` */
|
|
5
|
+
link: string;
|
|
6
|
+
/** Lets the agent's `runOnDevice` run code here. Off unless the developer opts in. */
|
|
7
|
+
allowEval?: boolean;
|
|
8
|
+
/** Packages remote modules may import by bare specifier. */
|
|
9
|
+
linked?: Record<string, object>;
|
|
10
|
+
host?: DeviceInfo['host'];
|
|
11
|
+
onStatus?(status: DesktopStatus, detail?: string): void;
|
|
12
|
+
onRendered?(info: {
|
|
13
|
+
version: number;
|
|
14
|
+
ms: number;
|
|
15
|
+
fetched: number;
|
|
16
|
+
}): void;
|
|
17
|
+
onError?(message: string): void;
|
|
18
|
+
onStream?(streaming: boolean, viewers: number): void;
|
|
19
|
+
/** Paths changed by an update, whether or not the entry re-runs (e.g. `web/` files an Electron host reloads). */
|
|
20
|
+
onChanges?(paths: string[]): void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Joins one edge project as a macOS device: runs its entry, applies hot
|
|
24
|
+
* updates, forwards console output and errors, answers RPCs, and streams the
|
|
25
|
+
* project's window to viewers through the project's stream relay.
|
|
26
|
+
*/
|
|
27
|
+
export declare class DesktopClient {
|
|
28
|
+
private options;
|
|
29
|
+
readonly projectUrl: string;
|
|
30
|
+
private deviceKey;
|
|
31
|
+
private stream;
|
|
32
|
+
private streamer;
|
|
33
|
+
private ws?;
|
|
34
|
+
private backoff;
|
|
35
|
+
private timer?;
|
|
36
|
+
private queue;
|
|
37
|
+
private renderedEntry;
|
|
38
|
+
private dispose?;
|
|
39
|
+
static parse(link: string): {
|
|
40
|
+
url: string;
|
|
41
|
+
key: string;
|
|
42
|
+
};
|
|
43
|
+
constructor(options: DesktopClientOptions);
|
|
44
|
+
connect(): void;
|
|
45
|
+
close(): void;
|
|
46
|
+
private open;
|
|
47
|
+
private deviceInfo;
|
|
48
|
+
private provides;
|
|
49
|
+
private send;
|
|
50
|
+
/** Desktop projects have no stylesheets; boot and update differ only in what they replace. */
|
|
51
|
+
private handle;
|
|
52
|
+
/**
|
|
53
|
+
* Imports the entry's versioned URL, closes what the previous version
|
|
54
|
+
* opened, then runs the new one. Only modules whose version changed are
|
|
55
|
+
* fetched. Node can't unload superseded modules, so they stay in memory
|
|
56
|
+
* until the host restarts.
|
|
57
|
+
*/
|
|
58
|
+
private render;
|
|
59
|
+
private disposeCurrent;
|
|
60
|
+
private answer;
|
|
61
|
+
private reportError;
|
|
62
|
+
private captureConsole;
|
|
63
|
+
/** Project code throwing from a native callback or a timer is reported; the host keeps running. */
|
|
64
|
+
private captureErrors;
|
|
65
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { installLoader, prefetch } from './loader.js';
|
|
3
|
+
import { handleRpc } from './rpc.js';
|
|
4
|
+
import { runtimeVersion } from './runtime.js';
|
|
5
|
+
import { formatArgs } from './serialize.js';
|
|
6
|
+
import { WindowStreamer } from './stream.js';
|
|
7
|
+
const PROJECT_LINK = /^(https?:\/\/[^/#]+\/p\/[0-9a-f]{32})\/?#([0-9a-f]{32})$/;
|
|
8
|
+
/** Plain HTTP is only for development servers on this machine or the LAN. */
|
|
9
|
+
const LOCAL_HOST = /^http:\/\/(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|[\w-]+\.local)(:\d+)?\//;
|
|
10
|
+
const MAX_BACKOFF_MS = 8000;
|
|
11
|
+
const RPC_TIMEOUT_MS = 15_000;
|
|
12
|
+
/**
|
|
13
|
+
* Joins one edge project as a macOS device: runs its entry, applies hot
|
|
14
|
+
* updates, forwards console output and errors, answers RPCs, and streams the
|
|
15
|
+
* project's window to viewers through the project's stream relay.
|
|
16
|
+
*/
|
|
17
|
+
export class DesktopClient {
|
|
18
|
+
options;
|
|
19
|
+
projectUrl;
|
|
20
|
+
deviceKey;
|
|
21
|
+
stream = randomBytes(16).toString('hex');
|
|
22
|
+
streamer;
|
|
23
|
+
ws;
|
|
24
|
+
backoff = 500;
|
|
25
|
+
timer;
|
|
26
|
+
queue = Promise.resolve();
|
|
27
|
+
renderedEntry = '';
|
|
28
|
+
dispose;
|
|
29
|
+
static parse(link) {
|
|
30
|
+
const m = PROJECT_LINK.exec(link.trim());
|
|
31
|
+
if (!m)
|
|
32
|
+
throw new Error(`Not a device link: ${link}\nExpected https://<host>/p/<project id>#<device key>`);
|
|
33
|
+
if (m[1].startsWith('http:') && !LOCAL_HOST.test(m[1] + '/'))
|
|
34
|
+
throw new Error(`Refusing plain HTTP to a non-local host: ${m[1]}`);
|
|
35
|
+
return { url: m[1], key: m[2] };
|
|
36
|
+
}
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
const { url, key } = DesktopClient.parse(options.link);
|
|
40
|
+
this.projectUrl = url;
|
|
41
|
+
this.deviceKey = key;
|
|
42
|
+
this.streamer = new WindowStreamer(`${url.replace(/^http/, 'ws')}/stream/${this.stream}?role=host&key=${key}`, options.onStream);
|
|
43
|
+
installLoader();
|
|
44
|
+
const linked = options.linked ?? {};
|
|
45
|
+
Object.assign(globalThis, {
|
|
46
|
+
__nsEdge: { require: (name) => linked[name] },
|
|
47
|
+
__APPLE__: true,
|
|
48
|
+
__MACOS__: true,
|
|
49
|
+
__IOS__: false,
|
|
50
|
+
__ANDROID__: false,
|
|
51
|
+
__VISIONOS__: false,
|
|
52
|
+
__DEV__: true,
|
|
53
|
+
});
|
|
54
|
+
this.captureConsole();
|
|
55
|
+
this.captureErrors();
|
|
56
|
+
}
|
|
57
|
+
connect() {
|
|
58
|
+
this.open();
|
|
59
|
+
this.streamer.open();
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
clearTimeout(this.timer);
|
|
63
|
+
const ws = this.ws;
|
|
64
|
+
this.ws = undefined;
|
|
65
|
+
ws?.close();
|
|
66
|
+
this.streamer.close();
|
|
67
|
+
}
|
|
68
|
+
open() {
|
|
69
|
+
this.options.onStatus?.('connecting', this.projectUrl);
|
|
70
|
+
const ws = new WebSocket(`${this.projectUrl.replace(/^http/, 'ws')}/ws?role=device&key=${this.deviceKey}`);
|
|
71
|
+
this.ws = ws;
|
|
72
|
+
ws.onopen = () => {
|
|
73
|
+
this.backoff = 500;
|
|
74
|
+
this.send({ type: 'hello', device: this.deviceInfo(), provides: this.provides() });
|
|
75
|
+
this.options.onStatus?.('connected');
|
|
76
|
+
};
|
|
77
|
+
ws.onmessage = (e) => {
|
|
78
|
+
if (this.ws !== ws)
|
|
79
|
+
return;
|
|
80
|
+
let msg;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(String(e.data));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// RPCs run beside the render queue so a hung eval can't stall hot reload.
|
|
88
|
+
if (msg.type === 'rpc')
|
|
89
|
+
return void this.answer(msg);
|
|
90
|
+
if (msg.type === 'boot')
|
|
91
|
+
this.queue = Promise.resolve();
|
|
92
|
+
this.queue = this.queue.then(() => this.handle(msg)).catch((err) => this.reportError(err));
|
|
93
|
+
};
|
|
94
|
+
ws.onclose = (e) => {
|
|
95
|
+
if (this.ws !== ws)
|
|
96
|
+
return;
|
|
97
|
+
if (e.code === 4404)
|
|
98
|
+
return this.options.onError?.('This project no longer exists.');
|
|
99
|
+
if (e.code === 4401)
|
|
100
|
+
return this.options.onError?.('This device link is not valid for the project.');
|
|
101
|
+
this.options.onStatus?.('offline');
|
|
102
|
+
this.timer = setTimeout(() => this.open(), this.backoff);
|
|
103
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
deviceInfo() {
|
|
107
|
+
const v = NSProcessInfo.processInfo.operatingSystemVersion;
|
|
108
|
+
return {
|
|
109
|
+
platform: 'macos',
|
|
110
|
+
model: String(NSProcessInfo.processInfo.hostName ?? 'Mac').replace(/\.local$/, ''),
|
|
111
|
+
osVersion: `${v.majorVersion}.${v.minorVersion}.${v.patchVersion}`,
|
|
112
|
+
runtimeVersion: runtimeVersion(),
|
|
113
|
+
coreVersion: '',
|
|
114
|
+
moduleLoader: true,
|
|
115
|
+
host: this.options.host ?? 'node',
|
|
116
|
+
stream: this.stream,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
provides() {
|
|
120
|
+
return Object.fromEntries(Object.entries(this.options.linked ?? {}).map(([name, mod]) => [name, Object.keys(mod)]));
|
|
121
|
+
}
|
|
122
|
+
send(msg) {
|
|
123
|
+
if (this.ws?.readyState === WebSocket.OPEN)
|
|
124
|
+
this.ws.send(JSON.stringify(msg));
|
|
125
|
+
}
|
|
126
|
+
/** Desktop projects have no stylesheets; boot and update differ only in what they replace. */
|
|
127
|
+
async handle(msg) {
|
|
128
|
+
if (msg.entry !== this.renderedEntry)
|
|
129
|
+
await this.render(msg.entry, msg.version);
|
|
130
|
+
if (msg.type === 'update')
|
|
131
|
+
this.options.onChanges?.(msg.changes.map((c) => c.path));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Imports the entry's versioned URL, closes what the previous version
|
|
135
|
+
* opened, then runs the new one. Only modules whose version changed are
|
|
136
|
+
* fetched. Node can't unload superseded modules, so they stay in memory
|
|
137
|
+
* until the host restarts.
|
|
138
|
+
*/
|
|
139
|
+
async render(entry, version) {
|
|
140
|
+
const started = performance.now();
|
|
141
|
+
try {
|
|
142
|
+
const fetched = await prefetch(entry);
|
|
143
|
+
const mod = await import(entry);
|
|
144
|
+
if (typeof mod.default !== 'function')
|
|
145
|
+
throw new Error('app/main.ts must default-export a function that opens the app and returns a function that closes it');
|
|
146
|
+
this.disposeCurrent();
|
|
147
|
+
const before = new Set(windowNumbers());
|
|
148
|
+
const result = await mod.default();
|
|
149
|
+
// An entry that returns nothing still gets its windows closed on the next update.
|
|
150
|
+
this.dispose = typeof result === 'function' ? result : () => closeWindowsOpenedSince(before);
|
|
151
|
+
this.renderedEntry = entry;
|
|
152
|
+
const ms = Math.round(performance.now() - started);
|
|
153
|
+
this.send({ type: 'rendered', version, ms });
|
|
154
|
+
this.options.onRendered?.({ version, ms, fetched });
|
|
155
|
+
this.streamer.retarget();
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
this.reportError(e);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
disposeCurrent() {
|
|
162
|
+
const dispose = this.dispose;
|
|
163
|
+
this.dispose = undefined;
|
|
164
|
+
try {
|
|
165
|
+
dispose?.();
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
this.reportError(e);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async answer(msg) {
|
|
172
|
+
let timer;
|
|
173
|
+
const timeout = new Promise((_, reject) => {
|
|
174
|
+
timer = setTimeout(() => reject(new Error(`no result within ${RPC_TIMEOUT_MS / 1000}s`)), RPC_TIMEOUT_MS);
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
this.send({ type: 'rpc:result', id: msg.id, ok: true, value: await Promise.race([handleRpc(msg, this.options.allowEval === true), timeout]) });
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
this.send({ type: 'rpc:result', id: msg.id, ok: false, error: describe(e) });
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
reportError(e) {
|
|
187
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
188
|
+
this.send({ type: 'error', message: err.message, stack: err.stack, ts: Date.now() });
|
|
189
|
+
this.options.onError?.(describe(err));
|
|
190
|
+
}
|
|
191
|
+
captureConsole() {
|
|
192
|
+
for (const level of ['log', 'info', 'warn', 'error']) {
|
|
193
|
+
const original = console[level].bind(console);
|
|
194
|
+
console[level] = (...args) => {
|
|
195
|
+
original(...args);
|
|
196
|
+
this.send({ type: 'log', level, args: formatArgs(args), ts: Date.now() });
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Project code throwing from a native callback or a timer is reported; the host keeps running. */
|
|
201
|
+
captureErrors() {
|
|
202
|
+
process.on('uncaughtException', (e) => this.reportError(e));
|
|
203
|
+
process.on('unhandledRejection', (e) => this.reportError(e));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function windowNumbers() {
|
|
207
|
+
const windows = NSApp.windows;
|
|
208
|
+
const out = [];
|
|
209
|
+
for (let i = 0; i < windows.count; i++)
|
|
210
|
+
out.push(windows.objectAtIndex(i).windowNumber);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function closeWindowsOpenedSince(before) {
|
|
214
|
+
const windows = NSApp.windows;
|
|
215
|
+
for (let i = windows.count - 1; i >= 0; i--) {
|
|
216
|
+
const w = windows.objectAtIndex(i);
|
|
217
|
+
if (!before.has(w.windowNumber))
|
|
218
|
+
w.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function describe(e) {
|
|
222
|
+
return e instanceof Error ? `${e.message}${e.stack ? `\n${e.stack.split('\n').slice(1, 6).join('\n')}` : ''}` : String(e);
|
|
223
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for projects running in an Electron host. A project imports them
|
|
3
|
+
* from '@nativescript/cloudflare-desktop'; the host links them in.
|
|
4
|
+
*/
|
|
5
|
+
/** The live NSView behind a BrowserWindow, for adding native views above its page. */
|
|
6
|
+
export declare function nativeView(win: {
|
|
7
|
+
getNativeWindowHandle(): Buffer;
|
|
8
|
+
}): any;
|
|
9
|
+
/** Where a project's `web/` file is served, for `BrowserWindow.loadURL`. */
|
|
10
|
+
export declare function webUrlFor(projectUrl: string): (path?: string) => string;
|
package/dist/electron.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for projects running in an Electron host. A project imports them
|
|
3
|
+
* from '@nativescript/cloudflare-desktop'; the host links them in.
|
|
4
|
+
*/
|
|
5
|
+
/** The live NSView behind a BrowserWindow, for adding native views above its page. */
|
|
6
|
+
export function nativeView(win) {
|
|
7
|
+
// Electron hands out the NSView* as a Buffer holding the raw pointer.
|
|
8
|
+
const handle = win.getNativeWindowHandle();
|
|
9
|
+
const slot = interop.alloc(8);
|
|
10
|
+
new interop.Reference(interop.types.pointer, slot).value = new interop.Pointer(handle.readBigUInt64LE(0));
|
|
11
|
+
const view = new interop.Reference(interop.types.id, slot).value;
|
|
12
|
+
interop.free(slot);
|
|
13
|
+
return view;
|
|
14
|
+
}
|
|
15
|
+
/** Where a project's `web/` file is served, for `BrowserWindow.loadURL`. */
|
|
16
|
+
export function webUrlFor(projectUrl) {
|
|
17
|
+
return (path = 'index.html') => `${projectUrl}/web/${path.replace(/^\/+/, '')}`;
|
|
18
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DesktopClient, type DesktopClientOptions, type DesktopStatus } from './client.js';
|
|
2
|
+
export { loadRuntime, type RuntimeOptions } from './runtime.js';
|
|
3
|
+
export { startApp } from './app.js';
|
|
4
|
+
export { InputInjector } from './input.js';
|
|
5
|
+
export { WindowStreamer, projectWindow } from './stream.js';
|
|
6
|
+
export { nativeView, webUrlFor } from './electron.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DesktopClient } from './client.js';
|
|
2
|
+
export { loadRuntime } from './runtime.js';
|
|
3
|
+
export { startApp } from './app.js';
|
|
4
|
+
export { InputInjector } from './input.js';
|
|
5
|
+
export { WindowStreamer, projectWindow } from './stream.js';
|
|
6
|
+
export { nativeView, webUrlFor } from './electron.js';
|
package/dist/input.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type StreamInput } from '@nativescript/cloudflare-protocol';
|
|
2
|
+
/**
|
|
3
|
+
* Turns viewer input into AppKit events for one window. Events go through
|
|
4
|
+
* the app's own queue (`postEvent`), the only path that drives controls'
|
|
5
|
+
* tracking loops; a direct `sendEvent` or `mouseDown` never presses an
|
|
6
|
+
* NSButton. Nothing reaches other apps, the real cursor never moves, and no
|
|
7
|
+
* Accessibility permission is involved.
|
|
8
|
+
*
|
|
9
|
+
* A press is held back until its release arrives, then the whole gesture is
|
|
10
|
+
* posted at once: a control's tracking loop runs synchronously on this
|
|
11
|
+
* thread from the press until the release, so a release still waiting in a
|
|
12
|
+
* socket message would never be read. Drags therefore show up when the
|
|
13
|
+
* pointer is released, and press-and-hold isn't possible.
|
|
14
|
+
*/
|
|
15
|
+
export declare class InputInjector {
|
|
16
|
+
private pressed;
|
|
17
|
+
private eventNumber;
|
|
18
|
+
private lastDown;
|
|
19
|
+
private gesture;
|
|
20
|
+
private gestureTimer?;
|
|
21
|
+
private lastPointer?;
|
|
22
|
+
inject(input: StreamInput, window: any): void;
|
|
23
|
+
private pointer;
|
|
24
|
+
private flush;
|
|
25
|
+
/** Ends a gesture whose release never came (the viewer left mid-drag). */
|
|
26
|
+
private release;
|
|
27
|
+
/** Double and triple clicks: AppKit reads the count from each event rather than inferring it. */
|
|
28
|
+
private clickCount;
|
|
29
|
+
private wheel;
|
|
30
|
+
private key;
|
|
31
|
+
}
|