anyos-client 0.2.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/connect.js ADDED
@@ -0,0 +1,265 @@
1
+ /* The picker. Everything shown is set with textContent; the only markup is
2
+ the page's own templates. `window.machines` is the preload's door. */
3
+ import { isConnectLine, readConnectLine } from './connect-line.js';
4
+
5
+ const { machines } = window;
6
+ const section = document.getElementById('machines');
7
+ const list = document.getElementById('list');
8
+ const problem = document.getElementById('problem');
9
+ const form = document.getElementById('form');
10
+ const address = document.getElementById('address');
11
+ const credentials = document.getElementById('credentials');
12
+ const certificate = document.getElementById('certificate');
13
+ const footer = document.getElementById('footer');
14
+ const openButton = document.getElementById('open');
15
+ const rowTemplate = document.getElementById('row');
16
+ const glyphs = { local: document.getElementById('glyph-local'), remote: document.getElementById('glyph-remote') };
17
+
18
+ let rows = []; /* the machines, as listed */
19
+ let local = null; /* this computer: running, installed, or absent */
20
+ let selected = null; /* the highlighted machine's id */
21
+ let editing = null; /* the id being edited; null while adding */
22
+ let tokenOnly = false; /* This computer: a token and nothing else to change */
23
+ let busy = false;
24
+
25
+ function say(text, tone = 'bad') {
26
+ problem.textContent = text ?? '';
27
+ problem.hidden = !text;
28
+ problem.classList.toggle('good', Boolean(text) && tone === 'good');
29
+ }
30
+
31
+ function render() {
32
+ list.textContent = '';
33
+ for (const machine of rows) {
34
+ const row = rowTemplate.content.firstElementChild.cloneNode(true);
35
+ const isLocal = machine.id === 'local';
36
+ row.classList.toggle('selected', machine.id === selected);
37
+ row.querySelector('.glyph').append(glyphs[isLocal ? 'local' : 'remote'].content.firstElementChild.cloneNode(true));
38
+ row.querySelector('.spec-item-title').textContent = machine.name;
39
+ const detail = row.querySelector('.spec-item-detail');
40
+ detail.textContent = isLocal ? (local?.detail ?? 'looking for a kernel…') : machine.url;
41
+ if (isLocal) {
42
+ if (local?.tone) detail.classList.add(`tone-${local.tone}`);
43
+ row.classList.toggle('unavailable', local?.state === 'absent');
44
+ /* This computer has no name and no address to change. It gets one tool,
45
+ and only when the kernel running on it turned out to want a token. */
46
+ if (local?.guarded) {
47
+ row.querySelector('.edit').remove();
48
+ row.querySelector('.remove').remove();
49
+ } else {
50
+ row.querySelector('.tools').remove();
51
+ }
52
+ } else {
53
+ row.querySelector('.token').remove(); /* a machine's token is in its Edit form */
54
+ }
55
+ row.addEventListener('click', () => select(machine.id));
56
+ row.addEventListener('dblclick', open);
57
+ row.querySelector('.token')?.addEventListener('click', event => { event.stopPropagation(); editToken(machine.id); });
58
+ row.querySelector('.edit')?.addEventListener('click', event => { event.stopPropagation(); edit(machine); });
59
+ row.querySelector('.remove')?.addEventListener('click', async event => {
60
+ event.stopPropagation();
61
+ await machines.remove(machine.id);
62
+ await refresh();
63
+ });
64
+ list.append(row);
65
+ }
66
+ openButton.disabled = busy || !selected || !openable(selected);
67
+ }
68
+
69
+ /* the one machine that can be unopenable: this computer, with no kernel on it */
70
+ const openable = id => id !== 'local' || local === null || local.state !== 'absent';
71
+
72
+ async function refresh() {
73
+ const answer = await machines.list();
74
+ if (!answer.ok) return say(answer.error);
75
+ rows = answer.machines;
76
+ if (!rows.some(m => m.id === selected)) selected = rows.some(m => m.id === answer.last) ? answer.last : 'local';
77
+ render();
78
+ await lookLocal();
79
+ }
80
+
81
+ /* Whether this computer has a kernel is a question with an HTTP request in
82
+ it, so the list is drawn first and the local row fills in when it answers. */
83
+ async function lookLocal() {
84
+ const answer = await machines.local();
85
+ local = answer.ok ? answer.local : { state: 'absent', detail: answer.error, tone: 'bad' };
86
+ render();
87
+ }
88
+
89
+ function select(id) { selected = id; render(); }
90
+
91
+ function move(step) {
92
+ const i = rows.findIndex(m => m.id === selected);
93
+ const next = rows[Math.min(rows.length - 1, Math.max(0, i + step))];
94
+ if (next) select(next.id);
95
+ }
96
+
97
+ async function open() {
98
+ if (busy || !selected || !openable(selected) || !form.hidden) return;
99
+ busy = true;
100
+ say(null);
101
+ section.classList.add('busy');
102
+ render();
103
+ const answer = await machines.open(selected);
104
+ busy = false;
105
+ section.classList.remove('busy');
106
+ render();
107
+ if (!answer.ok) { say(answer.error); await lookLocal(); }
108
+ /* on success the main process closes this window */
109
+ }
110
+
111
+ /* The kept token is put back in the field, so what is shown is what is
112
+ stored and clearing the field is how you forget it. */
113
+ async function secretFor(id) {
114
+ const answer = await machines.secret(id);
115
+ return answer.ok ? answer : { token: '', fingerprint: '' };
116
+ }
117
+
118
+ /**
119
+ * Adding a machine asks for a name and an address, and nothing else.
120
+ *
121
+ * A kernel reached by hand is one on a forward, and a forward needs neither a
122
+ * token nor a certificate — those belong to a kernel on a network, and they
123
+ * arrive by pasting its connect line, not by being typed. Editing an existing
124
+ * machine still shows them, because replacing a token after ./remote.sh renew
125
+ * is a real job and this is where it is done.
126
+ */
127
+ async function edit(machine) {
128
+ editing = machine?.id ?? null;
129
+ tokenOnly = false;
130
+ address.hidden = false;
131
+ credentials.hidden = !machine;
132
+ certificate.hidden = !machine;
133
+ form.elements.url.required = true;
134
+ form.elements.name.value = machine?.name ?? '';
135
+ form.elements.url.value = machine?.url ?? '';
136
+ const kept = machine ? await secretFor(machine.id) : { token: '', fingerprint: '' };
137
+ form.elements.token.value = kept.token;
138
+ form.elements.fingerprint.value = kept.fingerprint;
139
+ form.hidden = false;
140
+ footer.hidden = true;
141
+ say(null);
142
+ form.elements[machine ? 'name' : 'url'].focus();
143
+ }
144
+
145
+ async function editToken(id) {
146
+ editing = id;
147
+ tokenOnly = true;
148
+ address.hidden = true;
149
+ /* the address is not merely hidden — a required field nobody can see would
150
+ refuse to submit and say nothing about why */
151
+ form.elements.url.required = false;
152
+ credentials.hidden = false;
153
+ /* This computer is http on loopback; there is no certificate to pin */
154
+ certificate.hidden = true;
155
+ form.elements.token.value = (await secretFor(id)).token;
156
+ form.hidden = false;
157
+ footer.hidden = true;
158
+ say(null);
159
+ form.elements.token.focus();
160
+ }
161
+
162
+ function closeForm() {
163
+ form.hidden = true;
164
+ footer.hidden = false;
165
+ address.hidden = false;
166
+ credentials.hidden = false;
167
+ certificate.hidden = false;
168
+ form.elements.url.required = true;
169
+ form.elements.token.value = '';
170
+ form.elements.fingerprint.value = '';
171
+ editing = null;
172
+ tokenOnly = false;
173
+ say(null);
174
+ }
175
+
176
+ form.addEventListener('submit', async event => {
177
+ event.preventDefault();
178
+ const token = form.elements.token.value;
179
+ if (tokenOnly) {
180
+ const kept = await machines.token(editing, token);
181
+ if (!kept.ok) return say(kept.error);
182
+ closeForm();
183
+ return refresh();
184
+ }
185
+ const fields = {
186
+ name: form.elements.name.value,
187
+ url: form.elements.url.value,
188
+ token,
189
+ fingerprint: form.elements.fingerprint.value
190
+ };
191
+ const answer = editing ? await machines.update(editing, fields) : await machines.add(fields);
192
+ if (!answer.ok) return say(answer.error);
193
+ selected = answer.machine.id;
194
+ closeForm();
195
+ await refresh();
196
+ });
197
+ /**
198
+ * A connect line, pasted: it connects. Nothing to fill in, nothing to press.
199
+ *
200
+ * There was a review step here and it was taken out. None of these values
201
+ * were typed by anyone, so there is nothing to have mistyped, and a
202
+ * 64-character fingerprint is not something a person checks by eye — asking
203
+ * them to was friction that bought nothing. The form's fields stay for
204
+ * editing a machine later, which is a different job from arriving at one.
205
+ *
206
+ * The machine is saved before the connection is tried, so a kernel that is
207
+ * down leaves something in the list to retry rather than sending anyone back
208
+ * to the terminal for the line again.
209
+ */
210
+ async function connectWith(text, { loud = true } = {}) {
211
+ if (busy) return;
212
+ busy = true;
213
+ say('connecting…', 'good');
214
+ section.classList.add('busy');
215
+ render();
216
+ const answer = await machines.connect(text);
217
+ busy = false;
218
+ section.classList.remove('busy');
219
+ /* the form and the list settle first — closeForm clears the message line,
220
+ so anything said before this would be wiped by it */
221
+ closeForm();
222
+ await refresh();
223
+ if (!answer.ok && loud) say(answer.error);
224
+ else say(null);
225
+ /* when it opened, the main process closes this window and none of this shows */
226
+ }
227
+
228
+ /* pasted anywhere in the picker — into the address box, or at the list */
229
+ document.addEventListener('paste', event => {
230
+ const text = event.clipboardData?.getData('text') ?? '';
231
+ if (!isConnectLine(text)) return;
232
+ event.preventDefault();
233
+ connectWith(text);
234
+ });
235
+
236
+ /* a middle-click or a drag drops text without a paste event; act only once it
237
+ is a whole line, because this also fires on every keystroke */
238
+ form.elements.url.addEventListener('input', event => {
239
+ const text = event.target.value;
240
+ if (!isConnectLine(text)) return;
241
+ try { readConnectLine(text); } catch { return; }
242
+ connectWith(text, { loud: false });
243
+ });
244
+
245
+ document.getElementById('cancel').addEventListener('click', closeForm);
246
+ document.getElementById('add').addEventListener('click', () => edit(null));
247
+ openButton.addEventListener('click', open);
248
+
249
+ document.addEventListener('keydown', event => {
250
+ if (!form.hidden) { if (event.key === 'Escape') closeForm(); return; }
251
+ if (event.key === 'ArrowDown') { event.preventDefault(); move(1); }
252
+ else if (event.key === 'ArrowUp') { event.preventDefault(); move(-1); }
253
+ else if (event.key === 'Enter') { event.preventDefault(); open(); }
254
+ else if (event.key === 'Escape') window.close();
255
+ });
256
+
257
+ machines.onProblem(say);
258
+
259
+ /* say which copy this is, before anything else can go wrong */
260
+ machines.version().then(answer => {
261
+ if (!answer.ok) return;
262
+ document.getElementById('build').textContent = `anyos-client ${answer.version} · ${answer.from}`;
263
+ }).catch(() => {});
264
+
265
+ refresh();
package/guard.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The two answers this client gives Chromium about a kernel: which
3
+ * certificate it will accept, and which secret it will present.
4
+ *
5
+ * They live here rather than inline in main.js so a test can install the
6
+ * real ones on a real Electron app and watch them decide. A handler that is
7
+ * only ever read, or only ever tested as a copy of itself, is where this
8
+ * kind of bug survives — and both of these fail dangerous: the certificate
9
+ * one by trusting anybody, the login one by spinning forever.
10
+ *
11
+ * Chromium runs them in the order that matters, and it is the safe one: a
12
+ * certificate that does not verify means `login` never fires at all, so a
13
+ * credential is never offered to a machine that failed its pin.
14
+ */
15
+ import { X509Certificate } from 'node:crypto';
16
+ import { samePin } from './secrets.js';
17
+
18
+ /** host and port as `certificate-error` and `login` both name them. */
19
+ export function hostPort(url) {
20
+ try {
21
+ const parsed = new URL(url);
22
+ return [parsed.hostname, parsed.port || (parsed.protocol === 'https:' ? '443' : '80')];
23
+ } catch { return ['', '']; }
24
+ }
25
+
26
+ /**
27
+ * Which certificate is this machine's. A kernel's is self-signed, so every
28
+ * one of these arrives as an error and Chromium is right to object; what
29
+ * makes overruling it safe is that we overrule it for exactly one key.
30
+ *
31
+ * Two ways to get this wrong. Calling back true unconditionally accepts any
32
+ * certificate at all and is worse than plain http, because the padlock says
33
+ * otherwise. The subtler one: `certificate.fingerprint` is Chromium's own
34
+ * `sha256/<base64>` and can never equal what the kernel printed, so
35
+ * comparing it looks like a pinning bug and tempts someone into "fixing" it
36
+ * the first way. The PEM in `certificate.data` gives the kernel's own format.
37
+ *
38
+ * Fires per request, not per connection — a single page load can bring
39
+ * fifteen of these — so it stays cheap and decides the same way every time.
40
+ */
41
+ export function certificateGuard(keyring) {
42
+ return (event, _contents, url, _error, certificate, callback) => {
43
+ event.preventDefault();
44
+ const wanted = keyring.expected(...hostPort(url));
45
+ if (!wanted) return callback(false); /* nothing pinned is not permission */
46
+ let seen = '';
47
+ try { seen = new X509Certificate(certificate.data).fingerprint256; } catch { seen = ''; }
48
+ callback(samePin(seen, wanted));
49
+ };
50
+ }
51
+
52
+ /**
53
+ * The kernel's door. HTTP Basic on purpose: app code loaded by import(), the
54
+ * event stream and the first navigation cannot set a header of their own,
55
+ * but Chromium attaches a Basic credential to all of them once it has one —
56
+ * so this fires once and the whole desktop is authenticated.
57
+ *
58
+ * The kernel reads the password and ignores the user name.
59
+ */
60
+ export function loginGuard(keyring) {
61
+ return (event, _contents, _details, authInfo, callback) => {
62
+ if (authInfo.isProxy) return; /* a proxy's challenge is not ours to answer */
63
+ event.preventDefault();
64
+ const token = keyring.answer(authInfo.host, authInfo.port);
65
+ /* declining shows the kernel's own 401 rather than spinning on a password
66
+ box we have nothing to put in */
67
+ if (!token) return callback();
68
+ callback('anyos', token);
69
+ };
70
+ }
71
+
72
+ /** Install both on an Electron app. */
73
+ export function installGuards(app, keyring) {
74
+ app.on('certificate-error', certificateGuard(keyring));
75
+ app.on('login', loginGuard(keyring));
76
+ }
package/local.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The local kernel: whether one is running on this computer, and how to
3
+ * bring one up if it is only installed.
4
+ *
5
+ * The client does not contain a kernel. It looks for one, in the order a
6
+ * person would: is something already answering on the kernel's port — then
7
+ * that is the machine, and it is not ours to start or stop. Otherwise, is
8
+ * `anyos` installed here — then opening starts it, and what we started we
9
+ * stop when its window closes. If neither, there is no local machine, and
10
+ * the only way in is an address somewhere else.
11
+ *
12
+ * Where the kernel is found, in order: ANYOS_KERNEL if it is set (a command,
13
+ * or a path to the kernel's bin), then `anyos` on PATH, then the anyos package
14
+ * installed next to this one. Nothing here imports the kernel — a command is
15
+ * spawned and an address is probed, which is all a client may know.
16
+ *
17
+ * The kernel was called webos before it was called anyos, so each lookup tries
18
+ * the new name and then the old one: a machine that has not pulled the rename
19
+ * still starts. WEBOS_KERNEL is read after ANYOS_KERNEL for the same reason.
20
+ *
21
+ * A kernel started here is started behind a door: a secret minted per launch
22
+ * and passed in its environment, which the client can answer because it is
23
+ * the one that minted it. See mintToken at the foot of this file.
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { accessSync, constants } from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ import path from 'node:path';
30
+ import { reachable, DEFAULT_PORT } from './machines.js';
31
+
32
+ /* the kernel binds 127.0.0.1; `localhost` may resolve to ::1 first and be
33
+ refused by a kernel that is running perfectly well */
34
+ export const LOCAL_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
35
+
36
+ /* the kernel's command and package, the name it has now first — an install or
37
+ a checkout still on the old name is found by the second pass, not missed */
38
+ const KERNELS = [
39
+ { name: 'anyos', script: 'anyos/bin/anyos.js' },
40
+ { name: 'webos', script: 'webos/bin/webos.js' }
41
+ ];
42
+
43
+ const isExecutable = file => {
44
+ try { accessSync(file, constants.X_OK); return true; } catch { return false; }
45
+ };
46
+
47
+ /** A command on PATH, as an absolute path — or null. Windows tries PATHEXT. */
48
+ export function which(name, {
49
+ PATH = process.env.PATH ?? '',
50
+ PATHEXT = process.env.PATHEXT ?? '',
51
+ platform = process.platform,
52
+ executable = isExecutable
53
+ } = {}) {
54
+ const windows = platform === 'win32';
55
+ const exts = windows ? (PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : [''];
56
+ /* the separators are the named platform's, not the one we happen to run on */
57
+ const join = windows ? path.win32.join : path.posix.join;
58
+ for (const dir of PATH.split(windows ? ';' : ':').filter(Boolean)) {
59
+ for (const ext of exts) {
60
+ const full = join(dir, name + ext);
61
+ if (executable(full)) return full;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Where the kernel is on this computer: { command, args, from } to spawn, or
69
+ * null if it is not installed. `from` is for the sentence shown to a person,
70
+ * not for logic.
71
+ */
72
+ export function findKernel({
73
+ env = process.env,
74
+ resolve = specifier => createRequire(import.meta.url).resolve(specifier),
75
+ node = process.execPath,
76
+ ...lookup
77
+ } = {}) {
78
+ const named = String(env.ANYOS_KERNEL ?? '').trim() ? 'ANYOS_KERNEL' : 'WEBOS_KERNEL';
79
+ const explicit = String(env.ANYOS_KERNEL ?? env.WEBOS_KERNEL ?? '').trim();
80
+ if (explicit) {
81
+ return explicit.endsWith('.js') || explicit.endsWith('.mjs')
82
+ ? { command: node, args: [explicit], from: named }
83
+ : { command: explicit, args: [], from: named };
84
+ }
85
+
86
+ for (const { name } of KERNELS) {
87
+ const onPath = which(name, { ...lookup, PATH: env.PATH ?? '', PATHEXT: env.PATHEXT ?? '' });
88
+ if (onPath) return { command: onPath, args: [], from: 'PATH' };
89
+ }
90
+
91
+ /* installed as a package beside this one — the shape a `npm i anyos
92
+ anyos-client` or a checkout with both leaves behind */
93
+ for (const { name, script } of KERNELS) {
94
+ try {
95
+ const found = resolve(script);
96
+ if (found) return { command: node, args: [found], from: `the ${name} package` };
97
+ } catch { /* not installed here */ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * What the local machine is right now:
104
+ * running a kernel answers at LOCAL_URL — connect, do not own it
105
+ * installed anyos is here but nothing is listening — opening starts it
106
+ * absent no kernel on this computer; only an address will do
107
+ */
108
+ export async function localKernel({ url = LOCAL_URL, probe = reachable, ...options } = {}) {
109
+ const problem = await probe(url, { timeout: 1_500 });
110
+ if (!problem) return { state: 'running', url, kernel: findKernel(options) };
111
+ const kernel = findKernel(options);
112
+ return { state: kernel ? 'installed' : 'absent', url, kernel };
113
+ }
114
+
115
+ /** One line for the picker's local row, and how it should read. */
116
+ export function describe({ state, url, guarded, token } = {}) {
117
+ if (state === 'running') {
118
+ /* a kernel we did not start may have a door we have no key to; saying so
119
+ here is kinder than a window that opens onto a password box */
120
+ if (guarded && !token) return { detail: `a kernel is running at ${short(url)} — it wants a token`, tone: '' };
121
+ return { detail: `a kernel is running at ${short(url)}`, tone: 'good' };
122
+ }
123
+ if (state === 'installed') return { detail: 'anyos is installed here — opening starts it', tone: '' };
124
+ return { detail: 'anyos is not installed on this computer', tone: 'bad' };
125
+ }
126
+
127
+ const short = url => String(url ?? '').replace(/^https?:\/\//, '');
128
+
129
+ const wait = ms => new Promise(done => setTimeout(done, ms));
130
+ const lastLine = text => String(text).trim().split('\n').filter(Boolean).pop() ?? '';
131
+
132
+ /**
133
+ * Start the local kernel and resolve once it answers: { url, stop }.
134
+ *
135
+ * The port is the kernel's usual one, not a free one — a kernel this client
136
+ * started is still the computer's kernel, and the next client to look should
137
+ * find it where it belongs. If the child dies first, its last line is the
138
+ * error, because that is the sentence the kernel wrote about itself.
139
+ */
140
+ export function startKernel(kernel, {
141
+ port = DEFAULT_PORT,
142
+ url = `http://127.0.0.1:${port}`,
143
+ timeout = 20_000,
144
+ probe = reachable,
145
+ launch = spawn,
146
+ env = process.env,
147
+ token = mintToken(env)
148
+ } = {}) {
149
+ if (!kernel) return Promise.reject(new Error('anyos is not installed on this computer'));
150
+
151
+ const child = launch(kernel.command, [...kernel.args, '--port', String(port)], {
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ env: token ? { ...env, ANYOS_TOKEN: token } : { ...env }
154
+ });
155
+
156
+ let noise = '';
157
+ let done = null; /* how the child ended, if it has */
158
+ const listen = stream => stream?.on('data', chunk => { noise = (noise + chunk).slice(-4_096); });
159
+ child.stdout?.setEncoding('utf8');
160
+ child.stderr?.setEncoding('utf8');
161
+ listen(child.stdout);
162
+ listen(child.stderr);
163
+ child.on('error', err => { done = err.code === 'ENOENT' ? `${kernel.command} could not be run` : err.message; });
164
+ child.on('exit', code => { done ??= lastLine(noise) || `the kernel exited with code ${code}`; });
165
+
166
+ const stop = async () => { child.kill(); };
167
+
168
+ return (async () => {
169
+ const deadline = Date.now() + timeout;
170
+ for (;;) {
171
+ if (done) throw new Error(`the kernel did not start — ${done}`);
172
+ if (!await probe(url, { timeout: 1_500 })) return { url, stop, token };
173
+ if (Date.now() >= deadline) {
174
+ await stop();
175
+ throw new Error(`the kernel did not answer at ${short(url)} in ${Math.round(timeout / 1000)}s`);
176
+ }
177
+ await wait(200);
178
+ }
179
+ })();
180
+ }
181
+
182
+ /**
183
+ * The secret a kernel we start will ask for. Fresh bytes per launch, held in
184
+ * two process environments and never written down: the client knows it
185
+ * because it minted it, and answers the kernel's challenge with it.
186
+ *
187
+ * If the environment already names one, that is the owner's decision and it
188
+ * is inherited rather than replaced — the same token their other tools use.
189
+ *
190
+ * Only a kernel this client spawns gets a door this way. One already running
191
+ * is untouched, so `anyos` in a terminal stays as open as it ever was, and
192
+ * so does the browser tab pointed at it.
193
+ */
194
+ export function mintToken(env = process.env) {
195
+ const named = String(env.ANYOS_TOKEN ?? '').trim();
196
+ return named || randomBytes(32).toString('hex');
197
+ }