@myagentroam/node 0.13.11 → 0.13.12
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/connection/direct-transfer-service.js +60 -0
- package/dist/connection/endpoints.js +16 -0
- package/dist/connector.js +298 -10
- package/dist/ctags-binary.js +5 -1
- package/dist/managed-binaries.js +8 -0
- package/dist/marctl/package.json +1 -1
- package/dist/remote-desktop/desktop-byte-stream.js +128 -0
- package/dist/remote-desktop/desktop-error.js +35 -0
- package/dist/remote-desktop/desktop-service-core.js +384 -0
- package/dist/remote-desktop/direct-desktop-service.js +46 -0
- package/dist/remote-desktop/linux-managed-binaries.js +46 -0
- package/dist/remote-desktop/macos-managed-binaries.js +19 -0
- package/dist/remote-desktop/macos-remote-desktop-runtime.js +121 -0
- package/dist/remote-desktop/macvnc-managed-process.js +196 -0
- package/dist/remote-desktop/pooled-desktop-attachment.js +62 -0
- package/dist/remote-desktop/relay-desktop-service.js +40 -0
- package/dist/remote-desktop/remote-desktop-attachment.js +87 -0
- package/dist/remote-desktop/rfb-backend-handshake.js +98 -0
- package/dist/remote-desktop/rfb-client-gate.js +318 -0
- package/dist/remote-desktop/rfb-frontend-handshake.js +38 -0
- package/dist/remote-desktop/rfb-handshake-io.js +91 -0
- package/dist/remote-desktop/rfb-input-release.js +77 -0
- package/dist/remote-desktop/standard-rfb-attachment.js +40 -0
- package/dist/remote-desktop/tigervnc-attachment.js +68 -0
- package/dist/remote-desktop/tigervnc-backend-pool.js +88 -0
- package/dist/remote-desktop/tigervnc-cleanup-channel.js +129 -0
- package/dist/remote-desktop/tigervnc-managed-process.js +274 -0
- package/dist/remote-desktop/tigervnc-rfb-session.js +190 -0
- package/dist/remote-desktop/ultravnc-config.js +35 -0
- package/dist/remote-desktop/ultravnc-managed-process.js +180 -0
- package/dist/remote-desktop/vnc-password.js +15 -0
- package/dist/remote-desktop/windows-managed-binaries.js +18 -0
- package/dist/remote-desktop/windows-remote-desktop-runtime.js +126 -0
- package/dist/remote-desktop/x11-desktop-topology.js +82 -0
- package/dist/remote-desktop/x11-remote-desktop-runtime.js +99 -0
- package/dist/remote-desktop/x11-topology-source.js +205 -0
- package/dist/remote-desktop/x11-topology.js +88 -0
- package/dist/ripgrep-binary.js +5 -1
- package/dist/service/node-control-channel.js +7 -1
- package/dist/service/node-control-message-service.js +11 -0
- package/package.json +6 -6
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Duplex } from 'node:stream';
|
|
2
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
3
|
+
const MAX_QUEUED_BYTES = 1024 * 1024;
|
|
4
|
+
const DRAIN_MS = 20;
|
|
5
|
+
/** Sole byte-stream reader/writer/closer after a reliable RTC or relay channel handoff. */
|
|
6
|
+
export class DesktopByteStream extends Duplex {
|
|
7
|
+
channel;
|
|
8
|
+
maxMessageBytes;
|
|
9
|
+
#timer;
|
|
10
|
+
#pending;
|
|
11
|
+
constructor(channel, maxMessageBytes) {
|
|
12
|
+
super({
|
|
13
|
+
allowHalfOpen: false,
|
|
14
|
+
readableHighWaterMark: 64 * 1024,
|
|
15
|
+
writableHighWaterMark: 64 * 1024
|
|
16
|
+
});
|
|
17
|
+
this.channel = channel;
|
|
18
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
19
|
+
if ((channel.readyState !== 'open' && channel.readyState !== 1) ||
|
|
20
|
+
!Number.isSafeInteger(maxMessageBytes) ||
|
|
21
|
+
maxMessageBytes < 1 ||
|
|
22
|
+
maxMessageBytes > 64 * 1024)
|
|
23
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
24
|
+
channel.binaryType = 'arraybuffer';
|
|
25
|
+
channel.onmessage = this.#message;
|
|
26
|
+
channel.onclose = this.#closed;
|
|
27
|
+
channel.onerror = this.#failed;
|
|
28
|
+
}
|
|
29
|
+
_read() { }
|
|
30
|
+
_write(chunk, _encoding, callback) {
|
|
31
|
+
if (!Buffer.isBuffer(chunk) || this.writableLength > MAX_QUEUED_BYTES) {
|
|
32
|
+
callback(new RemoteDesktopError('DESKTOP_BACKPRESSURE'));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.#pending = { bytes: chunk, offset: 0, callback };
|
|
36
|
+
this.#drain();
|
|
37
|
+
}
|
|
38
|
+
_destroy(error, callback) {
|
|
39
|
+
if (this.#timer)
|
|
40
|
+
clearTimeout(this.#timer);
|
|
41
|
+
this.#timer = undefined;
|
|
42
|
+
this.channel.onmessage = null;
|
|
43
|
+
this.channel.onclose = null;
|
|
44
|
+
this.channel.onerror = null;
|
|
45
|
+
try {
|
|
46
|
+
if ('terminate' in this.channel)
|
|
47
|
+
this.channel.terminate();
|
|
48
|
+
else
|
|
49
|
+
this.channel.close();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* Preserve the original failure. */
|
|
53
|
+
}
|
|
54
|
+
const pending = this.#pending;
|
|
55
|
+
this.#pending = undefined;
|
|
56
|
+
pending?.callback(error ?? new RemoteDesktopError('DESKTOP_RFB_CLOSED'));
|
|
57
|
+
callback(error);
|
|
58
|
+
}
|
|
59
|
+
#message = (event) => {
|
|
60
|
+
const data = event.data;
|
|
61
|
+
const bytes = data instanceof ArrayBuffer
|
|
62
|
+
? new Uint8Array(data)
|
|
63
|
+
: ArrayBuffer.isView(data)
|
|
64
|
+
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
65
|
+
: undefined;
|
|
66
|
+
if (!bytes) {
|
|
67
|
+
this.destroy(new RemoteDesktopError('DESKTOP_RFB_INVALID'));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (this.readableLength + bytes.byteLength > MAX_QUEUED_BYTES) {
|
|
71
|
+
this.destroy(new RemoteDesktopError('DESKTOP_BACKPRESSURE'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (bytes.byteLength)
|
|
75
|
+
this.push(Buffer.from(bytes));
|
|
76
|
+
};
|
|
77
|
+
#closed = () => {
|
|
78
|
+
this.destroy();
|
|
79
|
+
};
|
|
80
|
+
#failed = () => {
|
|
81
|
+
this.destroy(new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR'));
|
|
82
|
+
};
|
|
83
|
+
#drain() {
|
|
84
|
+
const pending = this.#pending;
|
|
85
|
+
if (!pending || this.destroyed)
|
|
86
|
+
return;
|
|
87
|
+
if (this.channel.readyState !== 'open' && this.channel.readyState !== 1) {
|
|
88
|
+
this.destroy(new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
let sent = 0;
|
|
93
|
+
while (pending.offset < pending.bytes.length && sent < 4) {
|
|
94
|
+
const end = Math.min(pending.offset + this.maxMessageBytes, pending.bytes.length);
|
|
95
|
+
if (this.channel.bufferedAmount + end - pending.offset > MAX_QUEUED_BYTES) {
|
|
96
|
+
this.#timer = setTimeout(() => {
|
|
97
|
+
this.#timer = undefined;
|
|
98
|
+
this.#drain();
|
|
99
|
+
}, DRAIN_MS);
|
|
100
|
+
this.#timer.unref?.();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const chunk = pending.bytes.subarray(pending.offset, end);
|
|
104
|
+
// Both transports may retain the view after send returns; the Node
|
|
105
|
+
// stream write callback lets its caller immediately reuse the buffer.
|
|
106
|
+
const bytes = Uint8Array.from(chunk);
|
|
107
|
+
this.channel.send(bytes);
|
|
108
|
+
pending.offset = end;
|
|
109
|
+
sent++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
this.destroy(new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (pending.offset < pending.bytes.length) {
|
|
117
|
+
this.#timer = setTimeout(() => {
|
|
118
|
+
this.#timer = undefined;
|
|
119
|
+
this.#drain();
|
|
120
|
+
}, 0);
|
|
121
|
+
this.#timer.unref?.();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.#pending = undefined;
|
|
125
|
+
pending.callback();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
DESKTOP_UNAVAILABLE: 'Remote desktop is unavailable on this Node.',
|
|
3
|
+
DESKTOP_RFB_INVALID: 'The remote desktop protocol message is invalid.',
|
|
4
|
+
DESKTOP_RFB_CLOSED: 'The remote desktop input stream is closed.',
|
|
5
|
+
DESKTOP_INPUT_DENIED: 'This remote desktop attachment cannot send input.',
|
|
6
|
+
DESKTOP_CONTROL_LOST: 'Remote desktop control is no longer held.',
|
|
7
|
+
DESKTOP_BACKPRESSURE: 'The remote desktop input buffer exceeded its limit.',
|
|
8
|
+
DESKTOP_TRANSPORT_ERROR: 'The remote desktop transport failed.',
|
|
9
|
+
DESKTOP_BACKEND_AUTH_UNAVAILABLE: 'The desktop backend does not offer required authentication.',
|
|
10
|
+
DESKTOP_BACKEND_AUTH_FAILED: 'The desktop backend rejected authentication.',
|
|
11
|
+
DESKTOP_BACKEND_CONFIG_INVALID: 'The desktop backend configuration is invalid.',
|
|
12
|
+
DESKTOP_BACKEND_START_FAILED: 'The desktop backend could not start.',
|
|
13
|
+
DESKTOP_BACKEND_SOCKET_INVALID: 'The desktop backend socket is not private.',
|
|
14
|
+
DESKTOP_BACKEND_CLOSED: 'The desktop backend is closed.',
|
|
15
|
+
DESKTOP_BACKEND_STOP_FAILED: 'The desktop backend could not be stopped.',
|
|
16
|
+
DESKTOP_BACKEND_STOP_TIMEOUT: 'The desktop backend did not stop in time.',
|
|
17
|
+
DESKTOP_SETUP_TIMEOUT: 'The remote desktop setup timed out.',
|
|
18
|
+
DESKTOP_SETUP_CANCELLED: 'The remote desktop setup was cancelled.',
|
|
19
|
+
DESKTOP_TOPOLOGY_UNAVAILABLE: 'The native desktop topology is unavailable.',
|
|
20
|
+
DESKTOP_NATIVE_CHANNEL_CLOSED: 'The native desktop control channel is closed.',
|
|
21
|
+
DESKTOP_NATIVE_ID_UNAVAILABLE: 'A unique native desktop attachment ID could not be created.',
|
|
22
|
+
DESKTOP_NATIVE_WAIT_IN_PROGRESS: 'Native desktop cleanup is already being awaited.',
|
|
23
|
+
DESKTOP_NATIVE_CLEANUP_TIMEOUT: 'Native desktop input cleanup timed out.',
|
|
24
|
+
DESKTOP_NATIVE_CLEANUP_FAILED: 'The native desktop backend could not confirm input cleanup.',
|
|
25
|
+
DESKTOP_NATIVE_ATTACHMENTS_ACTIVE: 'Desktop attachments are still active.'
|
|
26
|
+
};
|
|
27
|
+
/** Stable, credential-free errors crossing the Node remote-desktop boundary. */
|
|
28
|
+
export class RemoteDesktopError extends Error {
|
|
29
|
+
code;
|
|
30
|
+
constructor(code) {
|
|
31
|
+
super(messages[code]);
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.name = 'RemoteDesktopError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { remoteDesktopDirectNodeCommandSchema, remoteDesktopRelayNodeCommandSchema, remoteDesktopDirectOpenFrameSchema } from '@myagentroam/protocol';
|
|
3
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
4
|
+
import { DesktopByteStream } from './desktop-byte-stream.js';
|
|
5
|
+
/** Tracks attachment IDs across transport owners until their cleanup settles. */
|
|
6
|
+
export class DesktopAttachmentRegistry {
|
|
7
|
+
#ids = new Set();
|
|
8
|
+
has(id) {
|
|
9
|
+
return this.#ids.has(id);
|
|
10
|
+
}
|
|
11
|
+
reserve(id) {
|
|
12
|
+
if (this.#ids.has(id))
|
|
13
|
+
return false;
|
|
14
|
+
this.#ids.add(id);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
release(id) {
|
|
18
|
+
this.#ids.delete(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Owns attachment channels, deadlines and cleanup for either path; never owns the shared backend. */
|
|
22
|
+
export class DesktopServiceCore {
|
|
23
|
+
options;
|
|
24
|
+
#entries = new Map();
|
|
25
|
+
#registry;
|
|
26
|
+
#now;
|
|
27
|
+
#timer;
|
|
28
|
+
#cleanupWaiters = new Set();
|
|
29
|
+
#disposed = false;
|
|
30
|
+
#suspended = false;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.#registry = options.registry ?? new DesktopAttachmentRegistry();
|
|
34
|
+
const epoch = Date.now() - performance.now();
|
|
35
|
+
this.#now = options.now ?? (() => Math.floor(epoch + performance.now()));
|
|
36
|
+
this.#timer = setInterval(() => this.#sweep(), 1_000);
|
|
37
|
+
this.#timer.unref();
|
|
38
|
+
}
|
|
39
|
+
handle(raw) {
|
|
40
|
+
const parsed = (this.options.path === 'direct'
|
|
41
|
+
? remoteDesktopDirectNodeCommandSchema
|
|
42
|
+
: remoteDesktopRelayNodeCommandSchema).safeParse(raw);
|
|
43
|
+
if (!parsed.success)
|
|
44
|
+
return false;
|
|
45
|
+
const command = parsed.data;
|
|
46
|
+
if (command.type === 'desktop.direct.prepare' || command.type === 'desktop.relay.prepare') {
|
|
47
|
+
this.#prepare(command);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const entry = this.#entries.get(command.attachmentId);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
// A shared ready event must reach the path that actually owns the attachment.
|
|
53
|
+
if (command.type === 'desktop.ready')
|
|
54
|
+
return false;
|
|
55
|
+
// Never claim another path's live attachment has already been cleaned up.
|
|
56
|
+
if (this.#registry.has(command.attachmentId))
|
|
57
|
+
return true;
|
|
58
|
+
if (command.type === 'desktop.direct.cancel' || command.type === 'desktop.relay.cancel')
|
|
59
|
+
this.#emit({
|
|
60
|
+
type: 'desktop.direct.closed',
|
|
61
|
+
attachmentId: command.attachmentId,
|
|
62
|
+
code: command.code
|
|
63
|
+
});
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (command.type === 'desktop.direct.cancel' || command.type === 'desktop.relay.cancel') {
|
|
67
|
+
this.#close(entry, command.code);
|
|
68
|
+
}
|
|
69
|
+
else if (command.type === 'desktop.direct.heartbeat' ||
|
|
70
|
+
command.type === 'desktop.relay.heartbeat') {
|
|
71
|
+
if (!this.#valid(entry) || command.expiresAt <= this.#now())
|
|
72
|
+
this.#close(entry, 'DESKTOP_LEASE_EXPIRED');
|
|
73
|
+
else
|
|
74
|
+
entry.expiresAt = Math.min(command.expiresAt, this.#now() + 30_000);
|
|
75
|
+
}
|
|
76
|
+
else if (entry.state === 'OPEN' || entry.state === 'ATTACHED') {
|
|
77
|
+
entry.ready = true;
|
|
78
|
+
this.#activate(entry);
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/** Called synchronously on topology, security session or notification-source invalidation. */
|
|
83
|
+
invalidate(code) {
|
|
84
|
+
for (const entry of this.#entries.values())
|
|
85
|
+
this.#close(entry, code);
|
|
86
|
+
}
|
|
87
|
+
removeTransport(transportSessionId) {
|
|
88
|
+
for (const entry of this.#entries.values())
|
|
89
|
+
if ('transportSessionId' in entry.command &&
|
|
90
|
+
entry.command.transportSessionId === transportSessionId)
|
|
91
|
+
this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
92
|
+
}
|
|
93
|
+
dispose() {
|
|
94
|
+
this.#disposed = true;
|
|
95
|
+
this.invalidate('DESKTOP_SERVER_SHUTDOWN');
|
|
96
|
+
if (this.#entries.size === 0)
|
|
97
|
+
clearInterval(this.#timer);
|
|
98
|
+
}
|
|
99
|
+
/** Fence synchronously and wait for native cleanup, not delivery of the closed event. */
|
|
100
|
+
shutdown(signal) {
|
|
101
|
+
this.dispose();
|
|
102
|
+
return this.#waitForCleanup(signal);
|
|
103
|
+
}
|
|
104
|
+
/** Disable Remote admission without disposing the borrowed transport or its retry owner. */
|
|
105
|
+
suspend(signal) {
|
|
106
|
+
this.#suspended = true;
|
|
107
|
+
this.invalidate('DESKTOP_UNAVAILABLE');
|
|
108
|
+
return this.#waitForCleanup(signal);
|
|
109
|
+
}
|
|
110
|
+
/** A failed or incomplete native cleanup must keep admission fenced. */
|
|
111
|
+
canResume() {
|
|
112
|
+
return (!this.#disposed && [...this.#entries.values()].every((entry) => entry.state === 'SETTLED'));
|
|
113
|
+
}
|
|
114
|
+
resume() {
|
|
115
|
+
if (!this.canResume())
|
|
116
|
+
return false;
|
|
117
|
+
this.#suspended = false;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
#waitForCleanup(signal) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const finish = () => {
|
|
123
|
+
if ([...this.#entries.values()].some((entry) => entry.state !== 'SETTLED')) {
|
|
124
|
+
if (!signal.aborted)
|
|
125
|
+
return;
|
|
126
|
+
cleanup();
|
|
127
|
+
reject(new RemoteDesktopError('DESKTOP_NATIVE_CLEANUP_TIMEOUT'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
cleanup();
|
|
131
|
+
resolve();
|
|
132
|
+
};
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
this.#cleanupWaiters.delete(finish);
|
|
135
|
+
signal.removeEventListener('abort', finish);
|
|
136
|
+
};
|
|
137
|
+
this.#cleanupWaiters.add(finish);
|
|
138
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
139
|
+
finish();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
#prepare(command) {
|
|
143
|
+
const existing = this.#entries.get(command.attachmentId);
|
|
144
|
+
if (existing)
|
|
145
|
+
return this.#close(existing, 'DESKTOP_RFB_INVALID');
|
|
146
|
+
// Another path owns this ID; it alone may confirm its cleanup.
|
|
147
|
+
if (this.#registry.has(command.attachmentId))
|
|
148
|
+
return;
|
|
149
|
+
if (this.#disposed ||
|
|
150
|
+
this.#suspended ||
|
|
151
|
+
!this.#current(command) ||
|
|
152
|
+
command.openExpiresAt <= this.#now() ||
|
|
153
|
+
command.expiresAt <= this.#now() ||
|
|
154
|
+
!this.#registry.reserve(command.attachmentId)) {
|
|
155
|
+
this.#emit({
|
|
156
|
+
type: 'desktop.direct.closed',
|
|
157
|
+
attachmentId: command.attachmentId,
|
|
158
|
+
code: 'DESKTOP_CONTROL_UNAVAILABLE'
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let transport;
|
|
163
|
+
try {
|
|
164
|
+
transport = this.options.transport(command);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
this.#registry.release(command.attachmentId);
|
|
168
|
+
this.#emit({
|
|
169
|
+
type: 'desktop.direct.closed',
|
|
170
|
+
attachmentId: command.attachmentId,
|
|
171
|
+
code: 'DESKTOP_TRANSPORT_ERROR'
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!transport) {
|
|
176
|
+
this.#registry.release(command.attachmentId);
|
|
177
|
+
this.#emit({
|
|
178
|
+
type: 'desktop.direct.closed',
|
|
179
|
+
attachmentId: command.attachmentId,
|
|
180
|
+
code: 'DESKTOP_CONTROL_UNAVAILABLE'
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const channel = transport.channel;
|
|
185
|
+
const entry = {
|
|
186
|
+
command,
|
|
187
|
+
channel,
|
|
188
|
+
transport,
|
|
189
|
+
deadline: new AbortController(),
|
|
190
|
+
openExpiresAt: Math.min(command.openExpiresAt, this.#now() + 15_000),
|
|
191
|
+
state: 'PREPARED',
|
|
192
|
+
expiresAt: Math.min(command.expiresAt, this.#now() + 30_000),
|
|
193
|
+
ready: false,
|
|
194
|
+
initialized: false,
|
|
195
|
+
closeCode: 'DESKTOP_TRANSPORT_ERROR'
|
|
196
|
+
};
|
|
197
|
+
this.#entries.set(command.attachmentId, entry);
|
|
198
|
+
channel.onmessage = (event) => this.#open(entry, event.data);
|
|
199
|
+
channel.onclose = () => this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
200
|
+
channel.onerror = () => this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
201
|
+
const prepared = () => {
|
|
202
|
+
if (!this.#valid(entry))
|
|
203
|
+
return this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
204
|
+
try {
|
|
205
|
+
transport.start?.();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
209
|
+
}
|
|
210
|
+
if (!this.#emit({
|
|
211
|
+
type: 'desktop.direct.ready',
|
|
212
|
+
attachmentId: command.attachmentId,
|
|
213
|
+
phase: 'PREPARED'
|
|
214
|
+
}))
|
|
215
|
+
this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
216
|
+
};
|
|
217
|
+
if (transport.start && channel.readyState !== 'open' && channel.readyState !== 1)
|
|
218
|
+
channel.onopen = prepared;
|
|
219
|
+
else
|
|
220
|
+
prepared();
|
|
221
|
+
}
|
|
222
|
+
#open(entry, data) {
|
|
223
|
+
if (entry.state !== 'PREPARED' ||
|
|
224
|
+
(entry.channel.readyState !== 'open' && entry.channel.readyState !== 1) ||
|
|
225
|
+
!this.#valid(entry) ||
|
|
226
|
+
typeof data !== 'string' ||
|
|
227
|
+
data.length > 8192)
|
|
228
|
+
return this.#close(entry, 'DESKTOP_RFB_INVALID');
|
|
229
|
+
let frame;
|
|
230
|
+
try {
|
|
231
|
+
frame = JSON.parse(data);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return this.#close(entry, 'DESKTOP_RFB_INVALID');
|
|
235
|
+
}
|
|
236
|
+
const parsed = remoteDesktopDirectOpenFrameSchema.safeParse(frame);
|
|
237
|
+
const expected = Buffer.from(entry.command.grant);
|
|
238
|
+
const received = parsed.success ? Buffer.from(parsed.data.grant) : Buffer.alloc(0);
|
|
239
|
+
if (!parsed.success ||
|
|
240
|
+
parsed.data.attachmentId !== entry.command.attachmentId ||
|
|
241
|
+
expected.length !== received.length ||
|
|
242
|
+
!timingSafeEqual(expected, received))
|
|
243
|
+
return this.#close(entry, 'DESKTOP_NOT_AUTHORIZED');
|
|
244
|
+
entry.state = 'OPEN';
|
|
245
|
+
entry.setupExpiresAt = this.#now() + 15_000;
|
|
246
|
+
if (!this.#emit({
|
|
247
|
+
type: 'desktop.direct.ready',
|
|
248
|
+
attachmentId: entry.command.attachmentId,
|
|
249
|
+
phase: 'OPEN'
|
|
250
|
+
}))
|
|
251
|
+
return this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
252
|
+
try {
|
|
253
|
+
entry.stream = new DesktopByteStream(entry.channel, entry.transport.maxMessageBytes());
|
|
254
|
+
entry.attachment = this.options.createAttachment(entry.command, entry.stream, {
|
|
255
|
+
attached: () => {
|
|
256
|
+
if (!this.#valid(entry))
|
|
257
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
258
|
+
entry.channel.send(JSON.stringify({ type: 'ATTACHED', attachmentId: entry.command.attachmentId }));
|
|
259
|
+
entry.state = 'ATTACHED';
|
|
260
|
+
if (!this.#emit({
|
|
261
|
+
type: 'desktop.direct.ready',
|
|
262
|
+
attachmentId: entry.command.attachmentId,
|
|
263
|
+
phase: 'ATTACHED'
|
|
264
|
+
}))
|
|
265
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
266
|
+
},
|
|
267
|
+
failed: (code) => this.#close(entry, code)
|
|
268
|
+
});
|
|
269
|
+
void entry.attachment.open(entry.deadline.signal).then(() => {
|
|
270
|
+
entry.initialized = true;
|
|
271
|
+
this.#activate(entry);
|
|
272
|
+
}, (error) => this.#close(entry, error instanceof RemoteDesktopError ? error.code : 'DESKTOP_TRANSPORT_ERROR'));
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
this.#close(entry, error instanceof RemoteDesktopError ? error.code : 'DESKTOP_TRANSPORT_ERROR');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
#activate(entry) {
|
|
279
|
+
if (!entry.ready || !entry.initialized || entry.state !== 'ATTACHED')
|
|
280
|
+
return;
|
|
281
|
+
if (!this.#valid(entry))
|
|
282
|
+
return this.#close(entry, 'DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
283
|
+
entry.state = 'ACTIVE';
|
|
284
|
+
if (!this.#emit({
|
|
285
|
+
type: 'desktop.direct.ready',
|
|
286
|
+
attachmentId: entry.command.attachmentId,
|
|
287
|
+
phase: 'ACTIVE'
|
|
288
|
+
}))
|
|
289
|
+
this.#close(entry, 'DESKTOP_TRANSPORT_ERROR');
|
|
290
|
+
}
|
|
291
|
+
#valid(entry) {
|
|
292
|
+
if (this.#disposed ||
|
|
293
|
+
entry.state === 'CLOSING' ||
|
|
294
|
+
entry.state === 'SETTLED' ||
|
|
295
|
+
entry.expiresAt <= this.#now() ||
|
|
296
|
+
(entry.state === 'PREPARED' && entry.openExpiresAt <= this.#now()) ||
|
|
297
|
+
((entry.state === 'OPEN' || entry.state === 'ATTACHED') &&
|
|
298
|
+
entry.setupExpiresAt <= this.#now()) ||
|
|
299
|
+
!this.#current(entry.command))
|
|
300
|
+
return false;
|
|
301
|
+
try {
|
|
302
|
+
return entry.transport.valid();
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
#current(command) {
|
|
309
|
+
try {
|
|
310
|
+
return this.options.isCurrent(command);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
#close(entry, code) {
|
|
317
|
+
if (entry.state === 'SETTLED')
|
|
318
|
+
return this.#announceClosed(entry);
|
|
319
|
+
if (entry.state !== 'CLOSING') {
|
|
320
|
+
entry.state = 'CLOSING';
|
|
321
|
+
entry.closeCode = code;
|
|
322
|
+
entry.deadline.abort();
|
|
323
|
+
if (!entry.attachment && entry.stream)
|
|
324
|
+
entry.stream.destroy();
|
|
325
|
+
else if (!entry.attachment) {
|
|
326
|
+
entry.channel.onmessage = null;
|
|
327
|
+
entry.channel.onopen = null;
|
|
328
|
+
entry.channel.onclose = null;
|
|
329
|
+
entry.channel.onerror = null;
|
|
330
|
+
try {
|
|
331
|
+
if ('terminate' in entry.channel)
|
|
332
|
+
entry.channel.terminate();
|
|
333
|
+
else
|
|
334
|
+
entry.channel.close();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* already closed */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (entry.cleanup)
|
|
342
|
+
return;
|
|
343
|
+
const cleanup = entry.attachment?.close(AbortSignal.timeout(5_000)) ?? Promise.resolve();
|
|
344
|
+
entry.cleanup = cleanup
|
|
345
|
+
.then(() => {
|
|
346
|
+
entry.state = 'SETTLED';
|
|
347
|
+
this.#announceClosed(entry);
|
|
348
|
+
for (const notify of this.#cleanupWaiters)
|
|
349
|
+
notify();
|
|
350
|
+
}, () => { })
|
|
351
|
+
.finally(() => {
|
|
352
|
+
delete entry.cleanup;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
#announceClosed(entry) {
|
|
356
|
+
if (this.#emit({
|
|
357
|
+
type: 'desktop.direct.closed',
|
|
358
|
+
attachmentId: entry.command.attachmentId,
|
|
359
|
+
code: entry.closeCode
|
|
360
|
+
})) {
|
|
361
|
+
this.#entries.delete(entry.command.attachmentId);
|
|
362
|
+
this.#registry.release(entry.command.attachmentId);
|
|
363
|
+
if (this.#disposed && this.#entries.size === 0)
|
|
364
|
+
clearInterval(this.#timer);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
#sweep() {
|
|
368
|
+
for (const entry of this.#entries.values()) {
|
|
369
|
+
if (entry.state === 'CLOSING' || entry.state === 'SETTLED')
|
|
370
|
+
this.#close(entry, entry.closeCode);
|
|
371
|
+
else if (!this.#valid(entry))
|
|
372
|
+
this.#close(entry, 'DESKTOP_LEASE_EXPIRED');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
#emit(event) {
|
|
376
|
+
try {
|
|
377
|
+
const type = event.type.replace('desktop.direct.', `desktop.${this.options.path}.`);
|
|
378
|
+
return this.options.emit({ ...event, type, nodeGeneration: this.options.nodeGeneration() });
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
2
|
+
import { DesktopServiceCore } from './desktop-service-core.js';
|
|
3
|
+
/** Binds reliable Node-created RTC channels to the shared attachment lifecycle. */
|
|
4
|
+
export class DirectDesktopService extends DesktopServiceCore {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
super({
|
|
7
|
+
path: 'direct',
|
|
8
|
+
...(options.registry === undefined ? {} : { registry: options.registry }),
|
|
9
|
+
nodeGeneration: options.nodeGeneration,
|
|
10
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
11
|
+
isCurrent: (command) => command.type === 'desktop.direct.prepare' && options.isCurrent(command),
|
|
12
|
+
createAttachment: (command, stream, callbacks) => {
|
|
13
|
+
if (command.type !== 'desktop.direct.prepare')
|
|
14
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
15
|
+
return options.createAttachment(command, stream, callbacks);
|
|
16
|
+
},
|
|
17
|
+
emit: (event) => options.emit(event),
|
|
18
|
+
transport: (command) => {
|
|
19
|
+
if (command.type !== 'desktop.direct.prepare')
|
|
20
|
+
return undefined;
|
|
21
|
+
let peer;
|
|
22
|
+
try {
|
|
23
|
+
peer = options.peer(command);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!peer)
|
|
29
|
+
return undefined;
|
|
30
|
+
const channel = peer.createDataChannel(`mar-desktop-${command.attachmentId}`, {
|
|
31
|
+
ordered: true
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
channel,
|
|
35
|
+
valid: () => options.peer(command) === peer,
|
|
36
|
+
maxMessageBytes: () => {
|
|
37
|
+
const negotiated = peer.sctp?.maxMessageSize;
|
|
38
|
+
if (negotiated === undefined || !Number.isFinite(negotiated) || negotiated < 0)
|
|
39
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
40
|
+
return negotiated === 0 ? 16_384 : Math.min(16_384, negotiated);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { managedBinariesPlatformDirectory } from '../managed-binaries.js';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
/** Resolves only targets included in the managed binaries package, never a system VNC. */
|
|
10
|
+
export async function linuxManagedDesktopBinaries(packageDirectory, platform = process.platform, architecture = process.arch) {
|
|
11
|
+
if (platform !== 'linux')
|
|
12
|
+
return undefined;
|
|
13
|
+
const target = `linux-${architecture}`;
|
|
14
|
+
if (target !== 'linux-x64')
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
const manifestDirectory = packageDirectory ??
|
|
18
|
+
dirname(createRequire(import.meta.url).resolve('@myagentroam/binaries/package.json'));
|
|
19
|
+
const vendor = resolve(manifestDirectory, 'vendor/tigervnc');
|
|
20
|
+
const manifest = JSON.parse(await readFile(resolve(vendor, 'manifest.json'), 'utf8'));
|
|
21
|
+
if (typeof manifest !== 'object' || manifest === null || !('targets' in manifest))
|
|
22
|
+
return undefined;
|
|
23
|
+
const targets = manifest.targets;
|
|
24
|
+
if (typeof targets !== 'object' || targets === null || !(target in targets))
|
|
25
|
+
return undefined;
|
|
26
|
+
const entry = targets[target];
|
|
27
|
+
if (typeof entry !== 'object' ||
|
|
28
|
+
entry === null ||
|
|
29
|
+
!('server' in entry) ||
|
|
30
|
+
!('topology' in entry))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (entry.server !== `${target}/x0vncserver` || entry.topology !== `${target}/x11-topology`)
|
|
33
|
+
return undefined;
|
|
34
|
+
const binaryVendor = packageDirectory === undefined
|
|
35
|
+
? resolve(managedBinariesPlatformDirectory(platform, architecture), 'vendor/tigervnc')
|
|
36
|
+
: vendor;
|
|
37
|
+
const binaryPath = resolve(binaryVendor, entry.server);
|
|
38
|
+
const topologyBinary = resolve(binaryVendor, entry.topology);
|
|
39
|
+
await Promise.all([access(binaryPath, constants.X_OK), access(topologyBinary, constants.X_OK)]);
|
|
40
|
+
await run(binaryPath, ['--version'], { timeout: 5_000, maxBuffer: 8_192 });
|
|
41
|
+
return { binaryPath, topologyBinary };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { managedBinariesPlatformDirectory } from '../managed-binaries.js';
|
|
5
|
+
/** Discovers the packaged macVNC backend and its display topology helper. */
|
|
6
|
+
export async function macosManagedDesktopBinaries(packageDirectory, platform = process.platform, architecture = process.arch) {
|
|
7
|
+
if (platform !== 'darwin' || !['arm64', 'x64'].includes(architecture))
|
|
8
|
+
return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const directory = join(packageDirectory ?? managedBinariesPlatformDirectory(platform, architecture), `vendor/macvnc/${platform}-${architecture}`);
|
|
11
|
+
const binaryPath = join(directory, 'macVNC');
|
|
12
|
+
const topologyBinary = join(directory, 'macos-topology');
|
|
13
|
+
await Promise.all([binaryPath, topologyBinary].map((path) => access(path, constants.X_OK)));
|
|
14
|
+
return { binaryPath, topologyBinary };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|