@myagentroam/node 0.13.11 → 0.13.13
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 +151 -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 +7 -6
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
2
|
+
/** Owns one Remote-only backend and its references, not attachments or desktop control leases. */
|
|
3
|
+
export class TigerVncBackendPool {
|
|
4
|
+
start;
|
|
5
|
+
onFailure;
|
|
6
|
+
#tail = Promise.resolve();
|
|
7
|
+
#backend;
|
|
8
|
+
#generation = 0;
|
|
9
|
+
#unavailable = false;
|
|
10
|
+
#retiring = false;
|
|
11
|
+
#references = new Set();
|
|
12
|
+
#disposed = false;
|
|
13
|
+
constructor(start, onFailure) {
|
|
14
|
+
this.start = start;
|
|
15
|
+
this.onFailure = onFailure;
|
|
16
|
+
}
|
|
17
|
+
/** Used alongside the native-session and topology checks when admitting input. */
|
|
18
|
+
isAvailable() {
|
|
19
|
+
return !this.#disposed && !this.#unavailable && !this.#retiring;
|
|
20
|
+
}
|
|
21
|
+
acquire(signal) {
|
|
22
|
+
return this.#serialize(async () => {
|
|
23
|
+
if (signal.aborted)
|
|
24
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
|
|
25
|
+
if (this.#disposed || this.#unavailable || this.#retiring)
|
|
26
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
27
|
+
if (!this.#backend) {
|
|
28
|
+
const generation = ++this.#generation;
|
|
29
|
+
const backend = await this.start(signal, () => {
|
|
30
|
+
if (generation !== this.#generation || this.#unavailable)
|
|
31
|
+
return;
|
|
32
|
+
this.#unavailable = true;
|
|
33
|
+
try {
|
|
34
|
+
this.onFailure();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// The unusable backend remains fenced even if its observer fails.
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
this.#backend = backend;
|
|
41
|
+
if (signal.aborted || this.#unavailable || this.#disposed) {
|
|
42
|
+
await this.#stopEmpty();
|
|
43
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const backend = this.#backend;
|
|
47
|
+
const token = Symbol();
|
|
48
|
+
this.#references.add(token);
|
|
49
|
+
return {
|
|
50
|
+
backend,
|
|
51
|
+
release: () => this.#serialize(() => this.#release(token))
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Disallow new references; existing owners still close their own connections. */
|
|
56
|
+
dispose() {
|
|
57
|
+
this.#disposed = true;
|
|
58
|
+
return this.#serialize(async () => {
|
|
59
|
+
if (this.#references.size)
|
|
60
|
+
throw new RemoteDesktopError('DESKTOP_NATIVE_ATTACHMENTS_ACTIVE');
|
|
61
|
+
await this.#stopEmpty();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async #release(token) {
|
|
65
|
+
if (!this.#references.has(token))
|
|
66
|
+
return;
|
|
67
|
+
if (this.#references.size === 1) {
|
|
68
|
+
// Keep the token and backend if stopping fails: only this owner can retry.
|
|
69
|
+
await this.#stopEmpty();
|
|
70
|
+
}
|
|
71
|
+
this.#references.delete(token);
|
|
72
|
+
}
|
|
73
|
+
async #stopEmpty() {
|
|
74
|
+
if (!this.#backend)
|
|
75
|
+
return;
|
|
76
|
+
this.#retiring = true;
|
|
77
|
+
await this.#backend.stop();
|
|
78
|
+
this.#backend = undefined;
|
|
79
|
+
this.#unavailable = false;
|
|
80
|
+
this.#retiring = false;
|
|
81
|
+
++this.#generation;
|
|
82
|
+
}
|
|
83
|
+
#serialize(operation) {
|
|
84
|
+
const result = this.#tail.then(operation);
|
|
85
|
+
this.#tail = result.then(() => { }, () => { });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
const marker = 0x4d415231;
|
|
4
|
+
const maxReceiptBytes = 64;
|
|
5
|
+
/** Owns the private native receipt stream, but neither the RFB stream nor control leases. */
|
|
6
|
+
export class TigerVncCleanupChannel {
|
|
7
|
+
stream;
|
|
8
|
+
onFailure;
|
|
9
|
+
#pending = new Map();
|
|
10
|
+
#line = '';
|
|
11
|
+
#failed = false;
|
|
12
|
+
constructor(stream, onFailure = () => { }) {
|
|
13
|
+
this.stream = stream;
|
|
14
|
+
this.onFailure = onFailure;
|
|
15
|
+
stream.on('data', this.#onData);
|
|
16
|
+
stream.on('error', this.#onFailure);
|
|
17
|
+
stream.on('end', this.#onFailure);
|
|
18
|
+
stream.on('close', this.#onClose);
|
|
19
|
+
}
|
|
20
|
+
get outstanding() {
|
|
21
|
+
return this.#pending.size;
|
|
22
|
+
}
|
|
23
|
+
issue() {
|
|
24
|
+
if (this.#failed || this.stream.destroyed || this.stream.readableEnded)
|
|
25
|
+
throw new RemoteDesktopError('DESKTOP_NATIVE_CHANNEL_CLOSED');
|
|
26
|
+
let token = '';
|
|
27
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
28
|
+
const candidate = randomBytes(16).toString('hex');
|
|
29
|
+
if (!this.#pending.has(candidate)) {
|
|
30
|
+
token = candidate;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!token)
|
|
35
|
+
throw new RemoteDesktopError('DESKTOP_NATIVE_ID_UNAVAILABLE');
|
|
36
|
+
const pending = { result: null };
|
|
37
|
+
this.#pending.set(token, pending);
|
|
38
|
+
const registration = Buffer.alloc(24);
|
|
39
|
+
registration[0] = 2;
|
|
40
|
+
registration.writeUInt16BE(5, 2);
|
|
41
|
+
registration.writeUInt32BE(marker, 4);
|
|
42
|
+
Buffer.from(token, 'hex').copy(registration, 8);
|
|
43
|
+
return {
|
|
44
|
+
registration,
|
|
45
|
+
wait: (signal) => new Promise((accept, fail) => {
|
|
46
|
+
if (pending.result) {
|
|
47
|
+
if (pending.result === 'OK')
|
|
48
|
+
accept();
|
|
49
|
+
else
|
|
50
|
+
fail(pending.result);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (pending.waiter) {
|
|
54
|
+
fail(new RemoteDesktopError('DESKTOP_NATIVE_WAIT_IN_PROGRESS'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (signal.aborted) {
|
|
58
|
+
fail(new RemoteDesktopError('DESKTOP_NATIVE_CLEANUP_TIMEOUT'));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const abort = () => {
|
|
62
|
+
delete pending.waiter;
|
|
63
|
+
fail(new RemoteDesktopError('DESKTOP_NATIVE_CLEANUP_TIMEOUT'));
|
|
64
|
+
};
|
|
65
|
+
pending.waiter = { signal, abort, resolve: accept, reject: fail };
|
|
66
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
67
|
+
})
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
dispose() {
|
|
71
|
+
this.#fail(false);
|
|
72
|
+
this.stream.destroy();
|
|
73
|
+
}
|
|
74
|
+
#onData = (chunk) => {
|
|
75
|
+
if (this.#failed)
|
|
76
|
+
return;
|
|
77
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
78
|
+
for (const byte of bytes) {
|
|
79
|
+
if (byte > 0x7f || this.#line.length >= maxReceiptBytes) {
|
|
80
|
+
this.#fail();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.#line += String.fromCharCode(byte);
|
|
84
|
+
if (byte !== 10)
|
|
85
|
+
continue;
|
|
86
|
+
const match = /^MAR1 ([0-9a-f]{32}) (OK|FAILED)\n$/.exec(this.#line);
|
|
87
|
+
this.#line = '';
|
|
88
|
+
const pending = match && this.#pending.get(match[1]);
|
|
89
|
+
if (!pending || !match) {
|
|
90
|
+
this.#fail();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.#pending.delete(match[1]);
|
|
94
|
+
this.#settle(pending, match[2] === 'OK' ? 'OK' : new RemoteDesktopError('DESKTOP_NATIVE_CLEANUP_FAILED'));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
#onFailure = () => this.#fail();
|
|
98
|
+
#onClose = () => {
|
|
99
|
+
this.#fail();
|
|
100
|
+
this.stream.off('error', this.#onFailure);
|
|
101
|
+
this.stream.off('close', this.#onClose);
|
|
102
|
+
};
|
|
103
|
+
#fail(notify = true) {
|
|
104
|
+
if (this.#failed)
|
|
105
|
+
return;
|
|
106
|
+
this.#failed = true;
|
|
107
|
+
this.stream.off('data', this.#onData);
|
|
108
|
+
this.stream.off('end', this.#onFailure);
|
|
109
|
+
for (const pending of this.#pending.values())
|
|
110
|
+
this.#settle(pending, new RemoteDesktopError('DESKTOP_NATIVE_CHANNEL_CLOSED'));
|
|
111
|
+
this.#pending.clear();
|
|
112
|
+
if (notify) {
|
|
113
|
+
this.stream.destroy();
|
|
114
|
+
this.onFailure();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
#settle(pending, result) {
|
|
118
|
+
pending.result = result;
|
|
119
|
+
const waiter = pending.waiter;
|
|
120
|
+
delete pending.waiter;
|
|
121
|
+
if (!waiter)
|
|
122
|
+
return;
|
|
123
|
+
waiter.signal.removeEventListener('abort', waiter.abort);
|
|
124
|
+
if (result === 'OK')
|
|
125
|
+
waiter.resolve();
|
|
126
|
+
else
|
|
127
|
+
waiter.reject(result);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createCipheriv, randomBytes } from 'node:crypto';
|
|
4
|
+
import { mkdtemp, open, rm, stat, unlink } from 'node:fs/promises';
|
|
5
|
+
import { connect } from 'node:net';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import { Duplex } from 'node:stream';
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
+
import { handshakeRfbBackend } from './rfb-backend-handshake.js';
|
|
11
|
+
import { TigerVncCleanupChannel } from './tigervnc-cleanup-channel.js';
|
|
12
|
+
function obfuscatePassword(secret) {
|
|
13
|
+
// TigerVNC's password file is an obfuscation, not a security boundary.
|
|
14
|
+
const key = Buffer.from('e84ad660c4721ae0'.repeat(3), 'hex');
|
|
15
|
+
try {
|
|
16
|
+
const cipher = createCipheriv('des-ede3', key, null);
|
|
17
|
+
cipher.setAutoPadding(false);
|
|
18
|
+
return Buffer.concat([cipher.update(secret), cipher.final()]);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
key.fill(0);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Owns one local TigerVNC process and private control stream, never a control lease. */
|
|
25
|
+
export class TigerVncManagedProcess {
|
|
26
|
+
child;
|
|
27
|
+
directory;
|
|
28
|
+
socketPath;
|
|
29
|
+
secret;
|
|
30
|
+
cleanup;
|
|
31
|
+
onUnexpectedExit;
|
|
32
|
+
#connections = new Set();
|
|
33
|
+
#exited;
|
|
34
|
+
#stopping = false;
|
|
35
|
+
#ready = false;
|
|
36
|
+
#didExit = false;
|
|
37
|
+
#notified = false;
|
|
38
|
+
#stopPromise;
|
|
39
|
+
#directoryCleanup;
|
|
40
|
+
constructor(child, directory, socketPath, secret, cleanup, onUnexpectedExit) {
|
|
41
|
+
this.child = child;
|
|
42
|
+
this.directory = directory;
|
|
43
|
+
this.socketPath = socketPath;
|
|
44
|
+
this.secret = secret;
|
|
45
|
+
this.cleanup = cleanup;
|
|
46
|
+
this.onUnexpectedExit = onUnexpectedExit;
|
|
47
|
+
this.#exited = new Promise((resolve) => {
|
|
48
|
+
child.once('close', () => {
|
|
49
|
+
this.#didExit = true;
|
|
50
|
+
this.secret.fill(0);
|
|
51
|
+
this.#notifyFailure();
|
|
52
|
+
this.#directoryCleanup = rm(this.directory, { recursive: true, force: true });
|
|
53
|
+
void this.#directoryCleanup.catch(() => { });
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
child.on('error', () => { });
|
|
58
|
+
child.stderr?.resume();
|
|
59
|
+
}
|
|
60
|
+
static async start(options, signal) {
|
|
61
|
+
if (process.platform !== 'linux' ||
|
|
62
|
+
!isAbsolute(options.binaryPath) ||
|
|
63
|
+
!options.display ||
|
|
64
|
+
!isAbsolute(options.xAuthority))
|
|
65
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
|
|
66
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
|
|
67
|
+
if (deadline.aborted)
|
|
68
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_TIMEOUT');
|
|
69
|
+
let directory;
|
|
70
|
+
try {
|
|
71
|
+
directory = await mkdtemp(join(tmpdir(), 'mar-rfb-'));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
|
|
75
|
+
}
|
|
76
|
+
const socketPath = join(directory, 'rfb.sock');
|
|
77
|
+
const passwordPath = join(directory, 'password');
|
|
78
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
79
|
+
const secret = randomBytes(8);
|
|
80
|
+
for (let i = 0; i < secret.length; i++)
|
|
81
|
+
secret[i] = alphabet.charCodeAt(secret[i] & 63);
|
|
82
|
+
let backend;
|
|
83
|
+
try {
|
|
84
|
+
const file = await open(passwordPath, 'wx', 0o600);
|
|
85
|
+
try {
|
|
86
|
+
const obfuscated = obfuscatePassword(secret);
|
|
87
|
+
try {
|
|
88
|
+
await file.writeFile(obfuscated);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
obfuscated.fill(0);
|
|
92
|
+
}
|
|
93
|
+
// The inherited descriptor keeps the unlinked password available only
|
|
94
|
+
// to this backend; a crashed Node leaves no password file behind.
|
|
95
|
+
await unlink(passwordPath);
|
|
96
|
+
const child = spawn(options.binaryPath, [
|
|
97
|
+
'-display',
|
|
98
|
+
options.display,
|
|
99
|
+
'-rfbport',
|
|
100
|
+
'-1',
|
|
101
|
+
'-rfbunixpath',
|
|
102
|
+
socketPath,
|
|
103
|
+
'-rfbunixmode',
|
|
104
|
+
'0600',
|
|
105
|
+
'-SecurityTypes',
|
|
106
|
+
'VncAuth',
|
|
107
|
+
'-PasswordFile',
|
|
108
|
+
'/proc/self/fd/4',
|
|
109
|
+
'-AlwaysShared',
|
|
110
|
+
'-AcceptSetDesktopSize=0',
|
|
111
|
+
'-MaxCutText=1048576'
|
|
112
|
+
], {
|
|
113
|
+
env: {
|
|
114
|
+
PATH: '/usr/bin:/bin',
|
|
115
|
+
HOME: directory,
|
|
116
|
+
DISPLAY: options.display,
|
|
117
|
+
XAUTHORITY: options.xAuthority,
|
|
118
|
+
MAR_RFB_CONTROL_FD: '3'
|
|
119
|
+
},
|
|
120
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', file.fd]
|
|
121
|
+
});
|
|
122
|
+
const control = child.stdio[3];
|
|
123
|
+
if (!(control instanceof Duplex)) {
|
|
124
|
+
child.on('error', () => { });
|
|
125
|
+
child.kill();
|
|
126
|
+
await new Promise((resolve) => child.once('close', () => resolve()));
|
|
127
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
|
|
128
|
+
}
|
|
129
|
+
backend = new TigerVncManagedProcess(child, directory, socketPath, secret, new TigerVncCleanupChannel(control), options.onUnexpectedExit);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await file.close();
|
|
133
|
+
}
|
|
134
|
+
while (!deadline.aborted) {
|
|
135
|
+
if (backend.#didExit ||
|
|
136
|
+
backend.child.exitCode !== null ||
|
|
137
|
+
backend.child.signalCode !== null)
|
|
138
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
|
|
139
|
+
try {
|
|
140
|
+
const info = await stat(socketPath);
|
|
141
|
+
if (!info.isSocket() || (info.mode & 0o777) !== 0o600 || info.uid !== process.getuid?.())
|
|
142
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_SOCKET_INVALID');
|
|
143
|
+
if (backend.#didExit)
|
|
144
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
|
|
145
|
+
backend.#ready = true;
|
|
146
|
+
return backend;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code !== 'ENOENT')
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
await delay(25, undefined, { signal: deadline });
|
|
153
|
+
}
|
|
154
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_TIMEOUT');
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
try {
|
|
158
|
+
if (backend)
|
|
159
|
+
await backend.forceStop();
|
|
160
|
+
else {
|
|
161
|
+
secret.fill(0);
|
|
162
|
+
await rm(directory, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_STOP_FAILED');
|
|
167
|
+
}
|
|
168
|
+
if (deadline.aborted)
|
|
169
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_TIMEOUT');
|
|
170
|
+
if (error instanceof RemoteDesktopError)
|
|
171
|
+
throw error;
|
|
172
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_START_FAILED');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async connect(maxPixels, signal) {
|
|
176
|
+
if (this.#stopping || this.child.exitCode !== null || this.child.signalCode !== null)
|
|
177
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
178
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
|
|
179
|
+
if (deadline.aborted)
|
|
180
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_TIMEOUT');
|
|
181
|
+
const socket = connect(this.socketPath);
|
|
182
|
+
this.#connections.add(socket);
|
|
183
|
+
socket.once('close', () => this.#connections.delete(socket));
|
|
184
|
+
try {
|
|
185
|
+
await new Promise((resolve, reject) => {
|
|
186
|
+
const cleanup = () => {
|
|
187
|
+
socket.off('connect', connected);
|
|
188
|
+
socket.off('error', failed);
|
|
189
|
+
deadline.removeEventListener('abort', aborted);
|
|
190
|
+
};
|
|
191
|
+
const connected = () => {
|
|
192
|
+
cleanup();
|
|
193
|
+
resolve();
|
|
194
|
+
};
|
|
195
|
+
const failed = () => {
|
|
196
|
+
cleanup();
|
|
197
|
+
reject(new RemoteDesktopError('DESKTOP_BACKEND_CLOSED'));
|
|
198
|
+
};
|
|
199
|
+
const aborted = () => {
|
|
200
|
+
cleanup();
|
|
201
|
+
reject(new RemoteDesktopError('DESKTOP_SETUP_TIMEOUT'));
|
|
202
|
+
};
|
|
203
|
+
socket.once('connect', connected);
|
|
204
|
+
socket.once('error', failed);
|
|
205
|
+
if (deadline.aborted)
|
|
206
|
+
aborted();
|
|
207
|
+
else
|
|
208
|
+
deadline.addEventListener('abort', aborted, { once: true });
|
|
209
|
+
});
|
|
210
|
+
const init = await handshakeRfbBackend(socket, this.secret, maxPixels, deadline);
|
|
211
|
+
return { socket, init };
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const ignoreLateError = () => { };
|
|
215
|
+
socket.on('error', ignoreLateError);
|
|
216
|
+
socket.once('close', () => socket.off('error', ignoreLateError));
|
|
217
|
+
socket.destroy();
|
|
218
|
+
if (error instanceof RemoteDesktopError)
|
|
219
|
+
throw error;
|
|
220
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
stop() {
|
|
224
|
+
if (this.#connections.size || this.cleanup.outstanding)
|
|
225
|
+
return Promise.reject(new RemoteDesktopError('DESKTOP_NATIVE_ATTACHMENTS_ACTIVE'));
|
|
226
|
+
return this.#stop();
|
|
227
|
+
}
|
|
228
|
+
/** Terminates an unusable backend; this never confirms or releases any lease. */
|
|
229
|
+
forceStop() {
|
|
230
|
+
return this.#stop();
|
|
231
|
+
}
|
|
232
|
+
#stop() {
|
|
233
|
+
if (this.#stopPromise)
|
|
234
|
+
return this.#stopPromise;
|
|
235
|
+
this.#stopping = true;
|
|
236
|
+
this.#stopPromise = (async () => {
|
|
237
|
+
for (const socket of this.#connections)
|
|
238
|
+
socket.destroy();
|
|
239
|
+
this.cleanup.dispose();
|
|
240
|
+
const timeout = await Promise.race([
|
|
241
|
+
this.#exited.then(() => false),
|
|
242
|
+
delay(5_000, undefined, { ref: false }).then(() => true)
|
|
243
|
+
]);
|
|
244
|
+
if (timeout) {
|
|
245
|
+
this.child.kill('SIGKILL');
|
|
246
|
+
const stillRunning = await Promise.race([
|
|
247
|
+
this.#exited.then(() => false),
|
|
248
|
+
delay(5_000, undefined, { ref: false }).then(() => true)
|
|
249
|
+
]);
|
|
250
|
+
if (stillRunning)
|
|
251
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_STOP_FAILED');
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
await (this.#directoryCleanup ??= rm(this.directory, { recursive: true, force: true }));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
this.#directoryCleanup = undefined;
|
|
258
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_STOP_FAILED');
|
|
259
|
+
}
|
|
260
|
+
})();
|
|
261
|
+
const stopping = this.#stopPromise;
|
|
262
|
+
void stopping.catch(() => {
|
|
263
|
+
if (this.#stopPromise === stopping)
|
|
264
|
+
this.#stopPromise = undefined;
|
|
265
|
+
});
|
|
266
|
+
return this.#stopPromise;
|
|
267
|
+
}
|
|
268
|
+
#notifyFailure() {
|
|
269
|
+
if (!this.#ready || this.#stopping || this.#notified)
|
|
270
|
+
return;
|
|
271
|
+
this.#notified = true;
|
|
272
|
+
this.onUnexpectedExit();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { RemoteDesktopError } from './desktop-error.js';
|
|
2
|
+
import { handshakeRfbFrontend } from './rfb-frontend-handshake.js';
|
|
3
|
+
import { RfbClientGate } from './rfb-client-gate.js';
|
|
4
|
+
import { StandardRfbAttachment } from './standard-rfb-attachment.js';
|
|
5
|
+
/** Keeps failed setup cleanup retryable without releasing the caller's lease. */
|
|
6
|
+
export class TigerVncSetupCleanupError extends RemoteDesktopError {
|
|
7
|
+
attachment;
|
|
8
|
+
constructor(code, attachment) {
|
|
9
|
+
super(code);
|
|
10
|
+
this.attachment = attachment;
|
|
11
|
+
this.name = 'TigerVncSetupCleanupError';
|
|
12
|
+
}
|
|
13
|
+
retryCleanup(signal) {
|
|
14
|
+
return this.attachment.close(signal);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Owns one authorized RFB stream pair, never a desktop lease. */
|
|
18
|
+
export class TigerVncRfbSession {
|
|
19
|
+
options;
|
|
20
|
+
socket;
|
|
21
|
+
gate;
|
|
22
|
+
attachment;
|
|
23
|
+
#closed = false;
|
|
24
|
+
#failed = false;
|
|
25
|
+
#detached = false;
|
|
26
|
+
#closing;
|
|
27
|
+
constructor(options, socket, gate, attachment) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.socket = socket;
|
|
30
|
+
this.gate = gate;
|
|
31
|
+
this.attachment = attachment;
|
|
32
|
+
}
|
|
33
|
+
/** Called only after the surrounding Server authorization has accepted OPEN. */
|
|
34
|
+
static async open(options, signal) {
|
|
35
|
+
const setup = new AbortController();
|
|
36
|
+
const deadline = AbortSignal.any([signal, setup.signal, AbortSignal.timeout(10_000)]);
|
|
37
|
+
const frontendFailed = () => setup.abort();
|
|
38
|
+
options.frontend.pause();
|
|
39
|
+
options.frontend.on('error', frontendFailed);
|
|
40
|
+
options.frontend.once('close', frontendFailed);
|
|
41
|
+
options.frontend.once('end', frontendFailed);
|
|
42
|
+
let attachment;
|
|
43
|
+
let socket;
|
|
44
|
+
let gateFailure;
|
|
45
|
+
try {
|
|
46
|
+
const connected = await options.backend.connect(options.maxPixels, deadline);
|
|
47
|
+
socket = connected.socket;
|
|
48
|
+
if (!options.isCurrentTopology(connected.init.width, connected.init.height))
|
|
49
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
50
|
+
let session;
|
|
51
|
+
const gate = new RfbClientGate(socket, options.mode, () => options.isCurrentTopology(connected.init.width, connected.init.height) &&
|
|
52
|
+
options.isControlOwner(), (code) => {
|
|
53
|
+
if (session)
|
|
54
|
+
session.#fail(code);
|
|
55
|
+
else {
|
|
56
|
+
gateFailure = code;
|
|
57
|
+
setup.abort();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
attachment = new StandardRfbAttachment(socket, gate);
|
|
61
|
+
if (gateFailure)
|
|
62
|
+
throw new RemoteDesktopError(gateFailure);
|
|
63
|
+
if (deadline.aborted)
|
|
64
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
|
|
65
|
+
if (!options.isCurrentTopology(connected.init.width, connected.init.height))
|
|
66
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
67
|
+
options.onAttached();
|
|
68
|
+
await handshakeRfbFrontend(options.frontend, connected.init.serverInit, deadline);
|
|
69
|
+
if (gateFailure)
|
|
70
|
+
throw new RemoteDesktopError(gateFailure);
|
|
71
|
+
if (deadline.aborted)
|
|
72
|
+
throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
|
|
73
|
+
if (!options.isCurrentTopology(connected.init.width, connected.init.height))
|
|
74
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
75
|
+
session = new TigerVncRfbSession(options, socket, gate, attachment);
|
|
76
|
+
session.#start();
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const ignore = () => { };
|
|
81
|
+
if (!options.frontend.closed) {
|
|
82
|
+
options.frontend.on('error', ignore);
|
|
83
|
+
options.frontend.once('close', () => options.frontend.off('error', ignore));
|
|
84
|
+
}
|
|
85
|
+
options.frontend.destroy();
|
|
86
|
+
if (attachment) {
|
|
87
|
+
try {
|
|
88
|
+
await attachment.close(AbortSignal.timeout(5_000));
|
|
89
|
+
}
|
|
90
|
+
catch (cleanupError) {
|
|
91
|
+
throw new TigerVncSetupCleanupError(cleanupError instanceof RemoteDesktopError
|
|
92
|
+
? cleanupError.code
|
|
93
|
+
: 'DESKTOP_NATIVE_CLEANUP_FAILED', attachment);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (socket) {
|
|
97
|
+
if (!socket.closed) {
|
|
98
|
+
socket.on('error', ignore);
|
|
99
|
+
socket.once('close', () => socket?.off('error', ignore));
|
|
100
|
+
}
|
|
101
|
+
socket.destroy();
|
|
102
|
+
}
|
|
103
|
+
if (gateFailure)
|
|
104
|
+
throw new RemoteDesktopError(gateFailure);
|
|
105
|
+
if (error instanceof RemoteDesktopError)
|
|
106
|
+
throw error;
|
|
107
|
+
throw new RemoteDesktopError('DESKTOP_TRANSPORT_ERROR');
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
options.frontend.off('error', frontendFailed);
|
|
111
|
+
options.frontend.off('close', frontendFailed);
|
|
112
|
+
options.frontend.off('end', frontendFailed);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Synchronously stop frontend input and output before attachment cleanup. */
|
|
116
|
+
fence() {
|
|
117
|
+
if (this.#closed || this.#closing)
|
|
118
|
+
return;
|
|
119
|
+
void this.gate.fence().catch(() => { });
|
|
120
|
+
this.#detach();
|
|
121
|
+
}
|
|
122
|
+
/** Fence input and await this backend's attachment cleanup contract. */
|
|
123
|
+
close(signal) {
|
|
124
|
+
if (this.#closed)
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
if (this.#closing)
|
|
127
|
+
return this.#closing;
|
|
128
|
+
this.fence();
|
|
129
|
+
const pending = this.attachment.close(signal).then(() => {
|
|
130
|
+
this.#closed = true;
|
|
131
|
+
});
|
|
132
|
+
this.#closing = pending.finally(() => {
|
|
133
|
+
this.#closing = undefined;
|
|
134
|
+
});
|
|
135
|
+
return this.#closing;
|
|
136
|
+
}
|
|
137
|
+
#start() {
|
|
138
|
+
this.options.frontend.on('data', this.#input);
|
|
139
|
+
this.options.frontend.on('error', this.#transportFailed);
|
|
140
|
+
this.options.frontend.on('end', this.#transportFailed);
|
|
141
|
+
this.options.frontend.on('close', this.#transportFailed);
|
|
142
|
+
this.socket.on('end', this.#transportFailed);
|
|
143
|
+
this.socket.on('close', this.#transportFailed);
|
|
144
|
+
this.socket.pipe(this.options.frontend, { end: false });
|
|
145
|
+
// Handshake explicitly paused this stream; resume only after the gate owns reads.
|
|
146
|
+
this.options.frontend.resume();
|
|
147
|
+
}
|
|
148
|
+
#input = (data) => {
|
|
149
|
+
if (!(data instanceof Uint8Array))
|
|
150
|
+
this.#fail('DESKTOP_RFB_INVALID');
|
|
151
|
+
else
|
|
152
|
+
this.gate.receive(data);
|
|
153
|
+
};
|
|
154
|
+
#transportFailed = () => this.#fail('DESKTOP_TRANSPORT_ERROR');
|
|
155
|
+
#fail(code) {
|
|
156
|
+
if (this.#failed || this.#closed || this.#closing)
|
|
157
|
+
return;
|
|
158
|
+
this.#failed = true;
|
|
159
|
+
// fence() stops admission synchronously even while its writes are draining.
|
|
160
|
+
void this.gate.fence().catch(() => { });
|
|
161
|
+
this.#detach();
|
|
162
|
+
try {
|
|
163
|
+
this.options.onFailure(code);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Resource cleanup cannot depend on a caller callback succeeding.
|
|
167
|
+
}
|
|
168
|
+
void this.close(AbortSignal.timeout(5_000)).catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
#detach() {
|
|
171
|
+
if (this.#detached)
|
|
172
|
+
return;
|
|
173
|
+
this.#detached = true;
|
|
174
|
+
this.options.frontend.pause();
|
|
175
|
+
this.options.frontend.off('data', this.#input);
|
|
176
|
+
this.options.frontend.off('error', this.#transportFailed);
|
|
177
|
+
this.options.frontend.off('end', this.#transportFailed);
|
|
178
|
+
this.options.frontend.off('close', this.#transportFailed);
|
|
179
|
+
this.socket.off('end', this.#transportFailed);
|
|
180
|
+
this.socket.off('close', this.#transportFailed);
|
|
181
|
+
this.socket.unpipe(this.options.frontend);
|
|
182
|
+
// Absorb a late transport error until destroy completes.
|
|
183
|
+
const ignore = () => { };
|
|
184
|
+
if (!this.options.frontend.closed) {
|
|
185
|
+
this.options.frontend.on('error', ignore);
|
|
186
|
+
this.options.frontend.once('close', () => this.options.frontend.off('error', ignore));
|
|
187
|
+
}
|
|
188
|
+
this.options.frontend.destroy();
|
|
189
|
+
}
|
|
190
|
+
}
|