@addai/node 0.29.1 → 0.30.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 +96 -96
- package/assets/vault-extension/background.js +45 -45
- package/assets/vault-extension/content.js +62 -62
- package/assets/vault-extension/manifest.json +39 -39
- package/dist/autostart-linux.js +20 -20
- package/dist/autostart-mac.js +30 -30
- package/dist/autostart-win.js +50 -50
- package/dist/cli.js +19 -19
- package/dist/desktop/install-engine.js +12 -12
- package/dist/desktop/learn/browser.js +4 -4
- package/dist/desktop/learn/injected.mjs +173 -173
- package/dist/desktop/learn/recorder.mjs +151 -151
- package/dist/desktop/manager.d.ts +15 -2
- package/dist/desktop/manager.js +38 -6
- package/dist/desktop/provider.js +9 -0
- package/dist/desktop/relay-client.js +29 -5
- package/dist/desktop/spec.d.ts +23 -0
- package/dist/desktop/spec.js +24 -1
- package/dist/desktop/vault-seed.mjs +112 -112
- package/package.json +62 -62
- package/scripts/copy-assets.js +18 -18
- package/scripts/fix-pty-helper.js +28 -28
- package/scripts/precompact-capture.js +292 -292
- package/scripts/probe-tui.mjs +122 -122
- package/scripts/smoke-test.sh +74 -74
|
@@ -1,151 +1,151 @@
|
|
|
1
|
-
// The CDP recorder. Runs INSIDE the desktop container, next to the browser.
|
|
2
|
-
//
|
|
3
|
-
// node /conf/learn/recorder.mjs
|
|
4
|
-
//
|
|
5
|
-
// Writes NDJSON on stdout, one event per line, `t` in ms since it attached.
|
|
6
|
-
// The daemon reads that stream directly — no file, no polling — and batches it
|
|
7
|
-
// into the lesson row.
|
|
8
|
-
//
|
|
9
|
-
// Nothing is installed to make this work: the image already has node 22, whose
|
|
10
|
-
// global WebSocket is enough for CDP, and Chrome, which the browser wrapper has
|
|
11
|
-
// already given a debug port. If the port is not there (a browser opened before
|
|
12
|
-
// the wrapper existed) it says so in one line and exits 3, and the lesson goes
|
|
13
|
-
// on without the browser track rather than failing.
|
|
14
|
-
import { injectedSource } from './injected.mjs';
|
|
15
|
-
|
|
16
|
-
const PORT = Number(process.env.ADDAI_LEARN_PORT || 9222);
|
|
17
|
-
const t0 = Date.now();
|
|
18
|
-
|
|
19
|
-
const emit = (o) => {
|
|
20
|
-
try { process.stdout.write(JSON.stringify({ t: Date.now() - t0, ...o }) + '\n'); }
|
|
21
|
-
catch { /* the daemon went away; nothing to do about it from here */ }
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
async function version() {
|
|
25
|
-
// A browser that is starting up refuses for a moment. Three tries over ~3s
|
|
26
|
-
// covers the case where a lesson begins in the same breath as the browser.
|
|
27
|
-
for (let i = 0; i < 3; i++) {
|
|
28
|
-
try {
|
|
29
|
-
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
30
|
-
if (r.ok) return await r.json();
|
|
31
|
-
} catch { /* not up yet */ }
|
|
32
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function client(url) {
|
|
38
|
-
const ws = new WebSocket(url);
|
|
39
|
-
let next = 1;
|
|
40
|
-
const pending = new Map();
|
|
41
|
-
const handlers = [];
|
|
42
|
-
|
|
43
|
-
const send = (method, params = {}, sessionId) =>
|
|
44
|
-
new Promise((resolve) => {
|
|
45
|
-
const id = next++;
|
|
46
|
-
pending.set(id, resolve);
|
|
47
|
-
const msg = { id, method, params };
|
|
48
|
-
if (sessionId) msg.sessionId = sessionId;
|
|
49
|
-
try { ws.send(JSON.stringify(msg)); } catch { pending.delete(id); resolve(null); }
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
ws.addEventListener('message', (ev) => {
|
|
53
|
-
let m;
|
|
54
|
-
try { m = JSON.parse(ev.data); } catch { return; }
|
|
55
|
-
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? null); pending.delete(m.id); return; }
|
|
56
|
-
for (const h of handlers) h(m);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
ws,
|
|
61
|
-
send,
|
|
62
|
-
on: (fn) => handlers.push(fn),
|
|
63
|
-
ready: new Promise((resolve, reject) => {
|
|
64
|
-
ws.addEventListener('open', resolve);
|
|
65
|
-
ws.addEventListener('error', () => reject(new Error('cdp socket refused')));
|
|
66
|
-
}),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function main() {
|
|
71
|
-
const v = await version();
|
|
72
|
-
if (!v || !v.webSocketDebuggerUrl) {
|
|
73
|
-
emit({ kind: 'no_port' });
|
|
74
|
-
process.exit(3);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const cdp = client(v.webSocketDebuggerUrl);
|
|
78
|
-
await cdp.ready;
|
|
79
|
-
|
|
80
|
-
const source = injectedSource();
|
|
81
|
-
const attached = new Set();
|
|
82
|
-
|
|
83
|
-
const arm = async (sessionId) => {
|
|
84
|
-
if (attached.has(sessionId)) return;
|
|
85
|
-
attached.add(sessionId);
|
|
86
|
-
await cdp.send('Runtime.enable', {}, sessionId);
|
|
87
|
-
await cdp.send('Page.enable', {}, sessionId);
|
|
88
|
-
// The binding is how the page talks back. Added before the script that
|
|
89
|
-
// calls it, or the first click on an already-open page is lost.
|
|
90
|
-
await cdp.send('Runtime.addBinding', { name: '__addaiLearn' }, sessionId);
|
|
91
|
-
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
|
|
92
|
-
// …and once for the document that is already loaded, which the line above
|
|
93
|
-
// does not cover.
|
|
94
|
-
await cdp.send('Runtime.evaluate', { expression: source }, sessionId);
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
cdp.on(async (m) => {
|
|
98
|
-
const s = m.sessionId;
|
|
99
|
-
if (m.method === 'Target.targetCreated' && m.params?.targetInfo?.type === 'page') {
|
|
100
|
-
await cdp.send('Target.attachToTarget', { targetId: m.params.targetInfo.targetId, flatten: true });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (m.method === 'Target.attachedToTarget') {
|
|
104
|
-
const info = m.params?.targetInfo ?? {};
|
|
105
|
-
if (info.type !== 'page') return;
|
|
106
|
-
emit({ kind: 'tab_open', url: info.url ?? null });
|
|
107
|
-
await arm(m.params.sessionId);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (m.method === 'Target.detachedFromTarget') {
|
|
111
|
-
attached.delete(m.params?.sessionId);
|
|
112
|
-
emit({ kind: 'tab_close' });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (m.method === 'Runtime.bindingCalled' && m.params?.name === '__addaiLearn') {
|
|
116
|
-
let ev;
|
|
117
|
-
try { ev = JSON.parse(m.params.payload); } catch { return; }
|
|
118
|
-
emit(ev);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (m.method === 'Page.frameNavigated' && !m.params?.frame?.parentId) {
|
|
122
|
-
emit({ kind: 'url', url: m.params.frame.url ?? null, title: null });
|
|
123
|
-
// A navigation replaces the execution context; re-arming is cheap and
|
|
124
|
-
// idempotent, and skipping it loses every click on the new page.
|
|
125
|
-
if (s) { attached.delete(s); await arm(s); }
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (m.method === 'Page.loadEventFired' && s) {
|
|
129
|
-
const r = await cdp.send('Runtime.evaluate',
|
|
130
|
-
{ expression: 'document.title', returnByValue: true }, s);
|
|
131
|
-
emit({ kind: 'load', title: r?.result?.value ?? null });
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (m.method === 'Page.javascriptDialogOpening') {
|
|
135
|
-
emit({ kind: 'dialog', dtype: m.params?.type ?? null, message: m.params?.message ?? null });
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
140
|
-
emit({ kind: 'watching' });
|
|
141
|
-
|
|
142
|
-
const bye = () => { try { cdp.ws.close(); } catch { /* already gone */ } process.exit(0); };
|
|
143
|
-
process.on('SIGTERM', bye);
|
|
144
|
-
process.on('SIGINT', bye);
|
|
145
|
-
cdp.ws.addEventListener('close', () => process.exit(0));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
main().catch((e) => {
|
|
149
|
-
emit({ kind: 'recorder_error', message: String(e && e.message ? e.message : e) });
|
|
150
|
-
process.exit(1);
|
|
151
|
-
});
|
|
1
|
+
// The CDP recorder. Runs INSIDE the desktop container, next to the browser.
|
|
2
|
+
//
|
|
3
|
+
// node /conf/learn/recorder.mjs
|
|
4
|
+
//
|
|
5
|
+
// Writes NDJSON on stdout, one event per line, `t` in ms since it attached.
|
|
6
|
+
// The daemon reads that stream directly — no file, no polling — and batches it
|
|
7
|
+
// into the lesson row.
|
|
8
|
+
//
|
|
9
|
+
// Nothing is installed to make this work: the image already has node 22, whose
|
|
10
|
+
// global WebSocket is enough for CDP, and Chrome, which the browser wrapper has
|
|
11
|
+
// already given a debug port. If the port is not there (a browser opened before
|
|
12
|
+
// the wrapper existed) it says so in one line and exits 3, and the lesson goes
|
|
13
|
+
// on without the browser track rather than failing.
|
|
14
|
+
import { injectedSource } from './injected.mjs';
|
|
15
|
+
|
|
16
|
+
const PORT = Number(process.env.ADDAI_LEARN_PORT || 9222);
|
|
17
|
+
const t0 = Date.now();
|
|
18
|
+
|
|
19
|
+
const emit = (o) => {
|
|
20
|
+
try { process.stdout.write(JSON.stringify({ t: Date.now() - t0, ...o }) + '\n'); }
|
|
21
|
+
catch { /* the daemon went away; nothing to do about it from here */ }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function version() {
|
|
25
|
+
// A browser that is starting up refuses for a moment. Three tries over ~3s
|
|
26
|
+
// covers the case where a lesson begins in the same breath as the browser.
|
|
27
|
+
for (let i = 0; i < 3; i++) {
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
30
|
+
if (r.ok) return await r.json();
|
|
31
|
+
} catch { /* not up yet */ }
|
|
32
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function client(url) {
|
|
38
|
+
const ws = new WebSocket(url);
|
|
39
|
+
let next = 1;
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
const handlers = [];
|
|
42
|
+
|
|
43
|
+
const send = (method, params = {}, sessionId) =>
|
|
44
|
+
new Promise((resolve) => {
|
|
45
|
+
const id = next++;
|
|
46
|
+
pending.set(id, resolve);
|
|
47
|
+
const msg = { id, method, params };
|
|
48
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
49
|
+
try { ws.send(JSON.stringify(msg)); } catch { pending.delete(id); resolve(null); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
ws.addEventListener('message', (ev) => {
|
|
53
|
+
let m;
|
|
54
|
+
try { m = JSON.parse(ev.data); } catch { return; }
|
|
55
|
+
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? null); pending.delete(m.id); return; }
|
|
56
|
+
for (const h of handlers) h(m);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
ws,
|
|
61
|
+
send,
|
|
62
|
+
on: (fn) => handlers.push(fn),
|
|
63
|
+
ready: new Promise((resolve, reject) => {
|
|
64
|
+
ws.addEventListener('open', resolve);
|
|
65
|
+
ws.addEventListener('error', () => reject(new Error('cdp socket refused')));
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const v = await version();
|
|
72
|
+
if (!v || !v.webSocketDebuggerUrl) {
|
|
73
|
+
emit({ kind: 'no_port' });
|
|
74
|
+
process.exit(3);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cdp = client(v.webSocketDebuggerUrl);
|
|
78
|
+
await cdp.ready;
|
|
79
|
+
|
|
80
|
+
const source = injectedSource();
|
|
81
|
+
const attached = new Set();
|
|
82
|
+
|
|
83
|
+
const arm = async (sessionId) => {
|
|
84
|
+
if (attached.has(sessionId)) return;
|
|
85
|
+
attached.add(sessionId);
|
|
86
|
+
await cdp.send('Runtime.enable', {}, sessionId);
|
|
87
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
88
|
+
// The binding is how the page talks back. Added before the script that
|
|
89
|
+
// calls it, or the first click on an already-open page is lost.
|
|
90
|
+
await cdp.send('Runtime.addBinding', { name: '__addaiLearn' }, sessionId);
|
|
91
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
|
|
92
|
+
// …and once for the document that is already loaded, which the line above
|
|
93
|
+
// does not cover.
|
|
94
|
+
await cdp.send('Runtime.evaluate', { expression: source }, sessionId);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
cdp.on(async (m) => {
|
|
98
|
+
const s = m.sessionId;
|
|
99
|
+
if (m.method === 'Target.targetCreated' && m.params?.targetInfo?.type === 'page') {
|
|
100
|
+
await cdp.send('Target.attachToTarget', { targetId: m.params.targetInfo.targetId, flatten: true });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (m.method === 'Target.attachedToTarget') {
|
|
104
|
+
const info = m.params?.targetInfo ?? {};
|
|
105
|
+
if (info.type !== 'page') return;
|
|
106
|
+
emit({ kind: 'tab_open', url: info.url ?? null });
|
|
107
|
+
await arm(m.params.sessionId);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (m.method === 'Target.detachedFromTarget') {
|
|
111
|
+
attached.delete(m.params?.sessionId);
|
|
112
|
+
emit({ kind: 'tab_close' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (m.method === 'Runtime.bindingCalled' && m.params?.name === '__addaiLearn') {
|
|
116
|
+
let ev;
|
|
117
|
+
try { ev = JSON.parse(m.params.payload); } catch { return; }
|
|
118
|
+
emit(ev);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (m.method === 'Page.frameNavigated' && !m.params?.frame?.parentId) {
|
|
122
|
+
emit({ kind: 'url', url: m.params.frame.url ?? null, title: null });
|
|
123
|
+
// A navigation replaces the execution context; re-arming is cheap and
|
|
124
|
+
// idempotent, and skipping it loses every click on the new page.
|
|
125
|
+
if (s) { attached.delete(s); await arm(s); }
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (m.method === 'Page.loadEventFired' && s) {
|
|
129
|
+
const r = await cdp.send('Runtime.evaluate',
|
|
130
|
+
{ expression: 'document.title', returnByValue: true }, s);
|
|
131
|
+
emit({ kind: 'load', title: r?.result?.value ?? null });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (m.method === 'Page.javascriptDialogOpening') {
|
|
135
|
+
emit({ kind: 'dialog', dtype: m.params?.type ?? null, message: m.params?.message ?? null });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
140
|
+
emit({ kind: 'watching' });
|
|
141
|
+
|
|
142
|
+
const bye = () => { try { cdp.ws.close(); } catch { /* already gone */ } process.exit(0); };
|
|
143
|
+
process.on('SIGTERM', bye);
|
|
144
|
+
process.on('SIGINT', bye);
|
|
145
|
+
cdp.ws.addEventListener('close', () => process.exit(0));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main().catch((e) => {
|
|
149
|
+
emit({ kind: 'recorder_error', message: String(e && e.message ? e.message : e) });
|
|
150
|
+
process.exit(1);
|
|
151
|
+
});
|
|
@@ -17,8 +17,21 @@ export declare function setStatus(id: string, fields: {
|
|
|
17
17
|
* but not recorded means "password check failed" at the RFB handshake. */
|
|
18
18
|
vnc_password?: string | null;
|
|
19
19
|
}): Promise<void>;
|
|
20
|
-
/** A free loopback port for this desktop
|
|
21
|
-
*
|
|
20
|
+
/** A free loopback port for this desktop — and, at a fixed offset from it, a
|
|
21
|
+
* free one for its xpra channel too.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ BOTH are checked, because only ONE is stored.
|
|
24
|
+
*
|
|
25
|
+
* The row carries a single port and the xpra port is computed from it (see
|
|
26
|
+
* XPRA_PORT_OFFSET), so a base whose partner is already taken would give a
|
|
27
|
+
* desktop whose windows channel quietly dials some other service on the
|
|
28
|
+
* machine. Binding the second proves it free at the moment of choosing.
|
|
29
|
+
*
|
|
30
|
+
* Nothing holds either port between here and `docker run` — that is the same
|
|
31
|
+
* race the single-port version always had, and sequential allocation by the
|
|
32
|
+
* OS makes it vanishingly unlikely. Several attempts rather than one because
|
|
33
|
+
* a busy machine can hand back a base whose partner happens to be in use,
|
|
34
|
+
* and retrying costs nothing next to failing to create the desktop. */
|
|
22
35
|
export declare function allocateVncPort(): Promise<number>;
|
|
23
36
|
export declare function ensureDirs(desktopId: string): {
|
|
24
37
|
confDir: string;
|
package/dist/desktop/manager.js
CHANGED
|
@@ -111,18 +111,50 @@ async function setStatus(id, fields) {
|
|
|
111
111
|
console.error('[desktops] status update failed:', err.message);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
-
/**
|
|
115
|
-
*
|
|
116
|
-
function
|
|
114
|
+
/** Bind a loopback port and read back what was actually bound. Port 0 asks the
|
|
115
|
+
* OS to choose, which is the only race-free way to pick a free one. */
|
|
116
|
+
function reserve(port) {
|
|
117
117
|
return new Promise((resolve, reject) => {
|
|
118
118
|
const srv = net.createServer();
|
|
119
119
|
srv.once('error', reject);
|
|
120
|
-
srv.listen(
|
|
121
|
-
const
|
|
122
|
-
srv.close(() => resolve(
|
|
120
|
+
srv.listen(port, '127.0.0.1', () => {
|
|
121
|
+
const got = srv.address().port;
|
|
122
|
+
srv.close(() => resolve(got));
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/** A free loopback port for this desktop — and, at a fixed offset from it, a
|
|
127
|
+
* free one for its xpra channel too.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ BOTH are checked, because only ONE is stored.
|
|
130
|
+
*
|
|
131
|
+
* The row carries a single port and the xpra port is computed from it (see
|
|
132
|
+
* XPRA_PORT_OFFSET), so a base whose partner is already taken would give a
|
|
133
|
+
* desktop whose windows channel quietly dials some other service on the
|
|
134
|
+
* machine. Binding the second proves it free at the moment of choosing.
|
|
135
|
+
*
|
|
136
|
+
* Nothing holds either port between here and `docker run` — that is the same
|
|
137
|
+
* race the single-port version always had, and sequential allocation by the
|
|
138
|
+
* OS makes it vanishingly unlikely. Several attempts rather than one because
|
|
139
|
+
* a busy machine can hand back a base whose partner happens to be in use,
|
|
140
|
+
* and retrying costs nothing next to failing to create the desktop. */
|
|
141
|
+
async function allocateVncPort() {
|
|
142
|
+
let lastErr = null;
|
|
143
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
144
|
+
const base = await reserve(0);
|
|
145
|
+
try {
|
|
146
|
+
await reserve(base + spec_1.XPRA_PORT_OFFSET);
|
|
147
|
+
return base;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
lastErr = err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Every attempt found the partner taken. Saying so beats handing back a port
|
|
154
|
+
// whose second half belongs to something else.
|
|
155
|
+
throw new Error('could not find a free port pair for this desktop after 8 tries: '
|
|
156
|
+
+ (lastErr?.message ?? 'unknown'));
|
|
157
|
+
}
|
|
126
158
|
function ensureDirs(desktopId) {
|
|
127
159
|
const confDir = (0, spec_1.confDirFor)(desktopId);
|
|
128
160
|
const workDir = (0, spec_1.workDirFor)(desktopId);
|
package/dist/desktop/provider.js
CHANGED
|
@@ -29,6 +29,15 @@ function buildCreateArgs(row, opts) {
|
|
|
29
29
|
// Loopback ONLY. Binding 0.0.0.0 would expose a logged-in desktop to the
|
|
30
30
|
// whole LAN; the relay reaches it from the daemon on the same host.
|
|
31
31
|
'-p', `127.0.0.1:${opts.vncPort}:${spec_1.VNC_PORT_IN_CONTAINER}`,
|
|
32
|
+
// The same desktop's windows, one at a time, over xpra. Published at a
|
|
33
|
+
// fixed offset from the port above because the row stores only that one —
|
|
34
|
+
// see XPRA_PORT_OFFSET, and allocateVncPort, which reserves the pair.
|
|
35
|
+
//
|
|
36
|
+
// Harmless on an image built before xpra existed: nothing listens inside,
|
|
37
|
+
// the publish still succeeds, and a viewer asking for the windows channel
|
|
38
|
+
// gets a refused connection that the bridge reports as "lost the desktop
|
|
39
|
+
// connection" rather than leaving them on a frozen frame.
|
|
40
|
+
'-p', `127.0.0.1:${opts.vncPort + spec_1.XPRA_PORT_OFFSET}:${spec_1.XPRA_PORT_IN_CONTAINER}`,
|
|
32
41
|
'-v', `${opts.confDir}:${spec_1.CONF_ROOT}`,
|
|
33
42
|
];
|
|
34
43
|
if (opts.workDir)
|
|
@@ -49,14 +49,17 @@ exports.stopRelayClient = stopRelayClient;
|
|
|
49
49
|
// whose socket is down; this is the fast path, not the only path.
|
|
50
50
|
//
|
|
51
51
|
// Two kinds of traffic share it:
|
|
52
|
-
// - RAW
|
|
53
|
-
//
|
|
52
|
+
// - RAW bytes for desktop viewers, on whichever channel they asked for: RFB
|
|
53
|
+
// for the whole screen, xpra for its windows one at a time. The bridge
|
|
54
|
+
// never parses either — it is a byte pipe, and that is what lets a second
|
|
55
|
+
// protocol ride it without this file learning anything about it.
|
|
54
56
|
// - `wake` control frames, which cost nothing when idle and turn a ten
|
|
55
57
|
// second wait into a round trip.
|
|
56
58
|
const ws_1 = __importDefault(require("ws"));
|
|
57
59
|
const net = __importStar(require("net"));
|
|
58
60
|
const store_1 = require("../store");
|
|
59
61
|
const manager_1 = require("./manager");
|
|
62
|
+
const spec_1 = require("./spec");
|
|
60
63
|
// The deployed relay. Overridable so a node can be pointed at a local one.
|
|
61
64
|
const RELAY_URL = process.env.AINODE_RELAY_URL
|
|
62
65
|
?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
|
|
@@ -182,9 +185,30 @@ async function connect() {
|
|
|
182
185
|
gone(msg.viewerId, 'desktop is not running');
|
|
183
186
|
return;
|
|
184
187
|
}
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
+
// WHICH stream of that desktop.
|
|
189
|
+
//
|
|
190
|
+
// 'screen' is the whole framebuffer over RFB, and is what every viewer
|
|
191
|
+
// asked for before this existed — so an absent or unrecognised channel
|
|
192
|
+
// means that. An older relay sends no channel at all and gets exactly
|
|
193
|
+
// the behaviour it always got.
|
|
194
|
+
//
|
|
195
|
+
// 'windows' is xpra on the container's second display, delivering each
|
|
196
|
+
// application window on its own. The bridge below does not care which:
|
|
197
|
+
// it is a byte pipe, and carries xpra's protocol as blindly as RFB.
|
|
198
|
+
//
|
|
199
|
+
// ⚠️ The xpra port is DERIVED, not stored. entity_node_desktops has one
|
|
200
|
+
// port column, and adding a second would mean a migration plus a fleet
|
|
201
|
+
// that disagrees with its own schema for the hour it takes to update.
|
|
202
|
+
// The container publishes 5900 and 14500, and the daemon maps the row's
|
|
203
|
+
// port to the first — so the second sits at a fixed offset from it.
|
|
204
|
+
// provider.ts is the other half of this bargain; if the published ports
|
|
205
|
+
// ever stop being a pair, these two lines are what know about it.
|
|
206
|
+
const port = msg.channel === 'windows'
|
|
207
|
+
? row.vnc_port + spec_1.XPRA_PORT_OFFSET
|
|
208
|
+
: row.vnc_port;
|
|
209
|
+
// Loopback only - the container published its ports on 127.0.0.1 and
|
|
210
|
+
// this process is the only thing on the machine that reaches them.
|
|
211
|
+
const tcp = net.connect(port, '127.0.0.1');
|
|
188
212
|
tcp.on('data', chunk => {
|
|
189
213
|
if (sock.readyState === ws_1.default.OPEN) {
|
|
190
214
|
sock.send(JSON.stringify({
|
package/dist/desktop/spec.d.ts
CHANGED
|
@@ -34,6 +34,29 @@ export declare const CONF_ROOT = "/conf";
|
|
|
34
34
|
* websockify in the container too would mean a WebSocket handshake tunnelled
|
|
35
35
|
* inside a WebSocket, which no client can read. */
|
|
36
36
|
export declare const VNC_PORT_IN_CONTAINER = 5900;
|
|
37
|
+
/** The container's xpra port, also fixed by the image.
|
|
38
|
+
*
|
|
39
|
+
* The same machine delivered a second way: 5900 is the whole framebuffer,
|
|
40
|
+
* this is its application windows one at a time. Raw for the same reason —
|
|
41
|
+
* xpra's own protocol carried across the relay's WebSocket, not tunnelled
|
|
42
|
+
* inside a second one. */
|
|
43
|
+
export declare const XPRA_PORT_IN_CONTAINER = 14500;
|
|
44
|
+
/** How far a desktop's host xpra port sits from its host VNC port.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ DERIVED rather than stored, and that is a deliberate trade.
|
|
47
|
+
*
|
|
48
|
+
* entity_node_desktops has one port column. A second would mean a migration,
|
|
49
|
+
* a reconciler that fills it, and — because the fleet auto-updates hourly —
|
|
50
|
+
* an hour in which some daemons write the new column and others do not, for
|
|
51
|
+
* rows both are reading. A fixed offset needs none of that: the pair is
|
|
52
|
+
* reserved together and both ends compute the same second number from the
|
|
53
|
+
* one value the row already carries.
|
|
54
|
+
*
|
|
55
|
+
* The cost is that the two ports must be allocated together and published
|
|
56
|
+
* together, or the offset points at something else's port. allocateVncPort
|
|
57
|
+
* and buildCreateArgs are the two halves of that bargain here;
|
|
58
|
+
* relay-client.ts is the third, on the far side of the wire. */
|
|
59
|
+
export declare const XPRA_PORT_OFFSET = 100;
|
|
37
60
|
/** Host directory bind-mounted at CONF_ROOT for one desktop. */
|
|
38
61
|
export declare function confDirFor(desktopId: string): string;
|
|
39
62
|
/** Host directory holding the desktop's persistent /work volume. */
|
package/dist/desktop/spec.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.VNC_PORT_IN_CONTAINER = exports.CONF_ROOT = exports.WORK_ROOT = void 0;
|
|
36
|
+
exports.XPRA_PORT_OFFSET = exports.XPRA_PORT_IN_CONTAINER = exports.VNC_PORT_IN_CONTAINER = exports.CONF_ROOT = exports.WORK_ROOT = void 0;
|
|
37
37
|
exports.confDirFor = confDirFor;
|
|
38
38
|
exports.workDirFor = workDirFor;
|
|
39
39
|
// Shapes and paths shared by every desktop module. No I/O here.
|
|
@@ -52,6 +52,29 @@ exports.CONF_ROOT = '/conf';
|
|
|
52
52
|
* websockify in the container too would mean a WebSocket handshake tunnelled
|
|
53
53
|
* inside a WebSocket, which no client can read. */
|
|
54
54
|
exports.VNC_PORT_IN_CONTAINER = 5900;
|
|
55
|
+
/** The container's xpra port, also fixed by the image.
|
|
56
|
+
*
|
|
57
|
+
* The same machine delivered a second way: 5900 is the whole framebuffer,
|
|
58
|
+
* this is its application windows one at a time. Raw for the same reason —
|
|
59
|
+
* xpra's own protocol carried across the relay's WebSocket, not tunnelled
|
|
60
|
+
* inside a second one. */
|
|
61
|
+
exports.XPRA_PORT_IN_CONTAINER = 14500;
|
|
62
|
+
/** How far a desktop's host xpra port sits from its host VNC port.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ DERIVED rather than stored, and that is a deliberate trade.
|
|
65
|
+
*
|
|
66
|
+
* entity_node_desktops has one port column. A second would mean a migration,
|
|
67
|
+
* a reconciler that fills it, and — because the fleet auto-updates hourly —
|
|
68
|
+
* an hour in which some daemons write the new column and others do not, for
|
|
69
|
+
* rows both are reading. A fixed offset needs none of that: the pair is
|
|
70
|
+
* reserved together and both ends compute the same second number from the
|
|
71
|
+
* one value the row already carries.
|
|
72
|
+
*
|
|
73
|
+
* The cost is that the two ports must be allocated together and published
|
|
74
|
+
* together, or the offset points at something else's port. allocateVncPort
|
|
75
|
+
* and buildCreateArgs are the two halves of that bargain here;
|
|
76
|
+
* relay-client.ts is the third, on the far side of the wire. */
|
|
77
|
+
exports.XPRA_PORT_OFFSET = 100;
|
|
55
78
|
/** Host directory bind-mounted at CONF_ROOT for one desktop. */
|
|
56
79
|
function confDirFor(desktopId) {
|
|
57
80
|
return path.join(paths_1.RUNTIME_HOME, 'desktops', desktopId, 'conf');
|