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/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/anyos.js +18 -0
- package/connect-line.js +75 -0
- package/connect.css +166 -0
- package/connect.html +68 -0
- package/connect.js +265 -0
- package/guard.js +76 -0
- package/local.js +197 -0
- package/machines.js +225 -0
- package/main.js +287 -0
- package/package.json +38 -0
- package/preload.cjs +18 -0
- package/secrets.js +203 -0
package/machines.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machines: where a kernel runs. The client keeps only this list — the
|
|
3
|
+
* workspace, the state dir and pid1's memory of you live with the kernel, so
|
|
4
|
+
* each machine is its own anyOS. "This Mac" is always first and means the
|
|
5
|
+
* kernel on this computer, found rather than stored — see local.js, which
|
|
6
|
+
* knows whether one is running or merely installed. Every other machine is
|
|
7
|
+
* an address where a kernel is already listening, and the window is pointed
|
|
8
|
+
* at it.
|
|
9
|
+
*
|
|
10
|
+
* The client does not start a remote kernel and does not own its lifetime.
|
|
11
|
+
* Reaching one that is not on this machine is the owner's arrangement — an
|
|
12
|
+
* ssh forward (`ssh -N -L 8765:127.0.0.1:8765 host`), a private network — and
|
|
13
|
+
* the address is whatever that arrangement makes local. The kernel binds
|
|
14
|
+
* loopback and authenticates nobody, so an address it answers on is a
|
|
15
|
+
* decision about the network, not about this list.
|
|
16
|
+
*
|
|
17
|
+
* Everything here runs without Electron: the store is one JSON file, an
|
|
18
|
+
* address is text, and whether a kernel answers is one HTTP request.
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import https from 'node:https';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { samePin } from './secrets.js';
|
|
25
|
+
|
|
26
|
+
export const LOCAL = 'local';
|
|
27
|
+
export const DEFAULT_PORT = 8765;
|
|
28
|
+
|
|
29
|
+
export const localMachine = () => ({
|
|
30
|
+
id: LOCAL,
|
|
31
|
+
name: process.platform === 'darwin' ? 'This Mac' : 'This computer',
|
|
32
|
+
url: null
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const oops = message => Object.assign(new Error(message), { status: 400 });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `host`, `host:port`, or either with a scheme — normalised to
|
|
39
|
+
* `http://host:port`. A bare host gets 8765, the port the kernel listens on.
|
|
40
|
+
* No path, no credentials: this names a machine, not a page on it.
|
|
41
|
+
*/
|
|
42
|
+
export function clean({ name, url } = {}) {
|
|
43
|
+
const raw = String(url ?? '').trim();
|
|
44
|
+
if (!raw) throw oops('an address is needed, like localhost:8765');
|
|
45
|
+
if (/\s/.test(raw)) throw oops(`"${raw}" is not an address`);
|
|
46
|
+
let parsed;
|
|
47
|
+
try { parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); } catch { throw oops(`"${raw}" is not an address`); }
|
|
48
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw oops('an address is http or https');
|
|
49
|
+
if (!parsed.hostname) throw oops(`"${raw}" is not an address`);
|
|
50
|
+
if (parsed.username || parsed.password) throw oops('an address carries no user — that was the ssh form');
|
|
51
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash) throw oops('an address is a host and a port, with nothing after it');
|
|
52
|
+
/* URL drops a port that is the scheme's default, so :443 and :80 come back
|
|
53
|
+
empty and would silently become 8765. Only a host with no port at all
|
|
54
|
+
gets the kernel's. */
|
|
55
|
+
const named = /:\d+$/.test(raw.replace(/^[a-z]+:\/\//i, ''));
|
|
56
|
+
const port = parsed.port || (named ? (parsed.protocol === 'https:' ? '443' : '80') : String(DEFAULT_PORT));
|
|
57
|
+
const address = `${parsed.protocol}//${parsed.hostname}:${port}`;
|
|
58
|
+
const label = String(name ?? '').trim() || `${parsed.hostname}:${port}`;
|
|
59
|
+
return { name: label, url: address };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createMachines(file) {
|
|
63
|
+
const data = load();
|
|
64
|
+
|
|
65
|
+
function load() {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
68
|
+
const machines = [];
|
|
69
|
+
for (const row of Array.isArray(parsed.machines) ? parsed.machines : []) {
|
|
70
|
+
try { machines.push({ id: String(row.id), ...clean(row) }); } catch { /* a broken row is dropped, not fatal */ }
|
|
71
|
+
}
|
|
72
|
+
const last = typeof parsed.last === 'string' ? parsed.last : LOCAL;
|
|
73
|
+
return { machines, last: machines.some(m => m.id === last) ? last : LOCAL };
|
|
74
|
+
} catch {
|
|
75
|
+
return { machines: [], last: LOCAL };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function save() {
|
|
79
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
|
|
81
|
+
}
|
|
82
|
+
function idFor(name) {
|
|
83
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'machine';
|
|
84
|
+
let id = base;
|
|
85
|
+
for (let n = 2; id === LOCAL || data.machines.some(m => m.id === id); n++) id = `${base}-${n}`;
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
list: () => [localMachine(), ...data.machines.map(m => ({ ...m }))],
|
|
91
|
+
last: () => data.last,
|
|
92
|
+
get: id => id === LOCAL ? localMachine() : data.machines.find(m => m.id === id) ?? null,
|
|
93
|
+
/* the machine already at an address, if there is one — a connect line for
|
|
94
|
+
somewhere already in the list replaces its credentials rather than
|
|
95
|
+
adding a second row for the same kernel */
|
|
96
|
+
byUrl(url) {
|
|
97
|
+
let want;
|
|
98
|
+
try { want = clean({ url }).url; } catch { return null; }
|
|
99
|
+
return data.machines.find(m => m.url === want) ?? null;
|
|
100
|
+
},
|
|
101
|
+
add(fields) {
|
|
102
|
+
const machine = clean(fields);
|
|
103
|
+
const row = { id: idFor(machine.name), ...machine };
|
|
104
|
+
data.machines.push(row);
|
|
105
|
+
save();
|
|
106
|
+
return { ...row };
|
|
107
|
+
},
|
|
108
|
+
update(id, fields) {
|
|
109
|
+
const i = data.machines.findIndex(m => m.id === id);
|
|
110
|
+
if (i < 0) throw oops('that machine is no longer in the list');
|
|
111
|
+
data.machines[i] = { id, ...clean(fields) };
|
|
112
|
+
save();
|
|
113
|
+
return { ...data.machines[i] };
|
|
114
|
+
},
|
|
115
|
+
remove(id) {
|
|
116
|
+
data.machines = data.machines.filter(m => m.id !== id);
|
|
117
|
+
if (data.last === id) data.last = LOCAL;
|
|
118
|
+
save();
|
|
119
|
+
},
|
|
120
|
+
used(id) { data.last = id; save(); }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A GET to a kernel, with its certificate held to the fingerprint pinned for
|
|
126
|
+
* it. Over http this is an ordinary request; over https the certificate is
|
|
127
|
+
* self-signed, so no authority can vouch for it and the pin is the whole of
|
|
128
|
+
* the check.
|
|
129
|
+
*
|
|
130
|
+
* `rejectUnauthorized: false` looks alarming and is the point: Node would
|
|
131
|
+
* refuse a self-signed certificate before we ever saw it, so the chain check
|
|
132
|
+
* is turned off and replaced with a stricter one — this exact key, not any
|
|
133
|
+
* key some authority on this machine would sign. A pin that does not match
|
|
134
|
+
* destroys the socket at the handshake.
|
|
135
|
+
*
|
|
136
|
+
* Safe to check after the handshake rather than before only because these two
|
|
137
|
+
* routes carry no credential. Everything that does travels through the window,
|
|
138
|
+
* where certificate-error in main.js holds the same pin.
|
|
139
|
+
*/
|
|
140
|
+
function ask(url, route, { timeout, pin }, onResponse, onProblem) {
|
|
141
|
+
const secure = url.startsWith('https:');
|
|
142
|
+
if (secure && !pin) {
|
|
143
|
+
onProblem(`${url} is https, but no certificate is pinned for it — add its fingerprint`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const agent = secure ? https : http;
|
|
147
|
+
/* agent: false — a pooled socket from a previous probe comes back reset
|
|
148
|
+
rather than refused, which reads as a kernel that hung up on us */
|
|
149
|
+
const options = { timeout, agent: false };
|
|
150
|
+
if (secure) options.rejectUnauthorized = false;
|
|
151
|
+
const request = agent.get(`${url}${route}`, options, onResponse);
|
|
152
|
+
if (secure) {
|
|
153
|
+
request.on('socket', socket => socket.on('secureConnect', () => {
|
|
154
|
+
const seen = socket.getPeerX509Certificate?.()?.fingerprint256 ?? '';
|
|
155
|
+
if (samePin(seen, pin)) return;
|
|
156
|
+
socket.destroy();
|
|
157
|
+
onProblem(seen
|
|
158
|
+
? `${url} is not the machine you pinned — its certificate has changed`
|
|
159
|
+
: `${url} offered no certificate`);
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
return request;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Does a kernel answer here? Resolves null when one does, or one sentence
|
|
167
|
+
* saying what is wrong — the picker shows it instead of opening a window on
|
|
168
|
+
* nothing. `/fs/health` is the cheapest syscall that proves it is a kernel
|
|
169
|
+
* and not merely something listening.
|
|
170
|
+
*/
|
|
171
|
+
export function reachable(url, { timeout = 4_000, pin = null } = {}) {
|
|
172
|
+
return new Promise(resolve => {
|
|
173
|
+
let settled = false;
|
|
174
|
+
const done = answer => { if (!settled) { settled = true; resolve(answer); } };
|
|
175
|
+
const request = ask(url, '/fs/health', { timeout, pin }, response => {
|
|
176
|
+
if (response.statusCode !== 200) {
|
|
177
|
+
response.resume();
|
|
178
|
+
return done(`${url} answered ${response.statusCode}, which is not a kernel`);
|
|
179
|
+
}
|
|
180
|
+
let body = '';
|
|
181
|
+
response.setEncoding('utf8');
|
|
182
|
+
response.on('data', chunk => { body += chunk.slice(0, 4_096); });
|
|
183
|
+
response.on('end', () => {
|
|
184
|
+
try { done(JSON.parse(body).ok ? null : `${url} answered, but not like a kernel`); }
|
|
185
|
+
catch { done(`${url} answered, but not like a kernel`); }
|
|
186
|
+
});
|
|
187
|
+
}, done);
|
|
188
|
+
request?.on('timeout', () => { request.destroy(); done(`${url} did not answer in ${Math.round(timeout / 1000)}s`); });
|
|
189
|
+
request?.on('error', err => done(explain(err, url)));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Why an address did not answer, in one sentence the owner can act on. */
|
|
194
|
+
export function explain(err, url) {
|
|
195
|
+
const host = new URL(url).hostname;
|
|
196
|
+
if (err.code === 'ECONNREFUSED') return `nothing is listening at ${url} — is its kernel running?`;
|
|
197
|
+
if (err.code === 'ENOTFOUND' || err.code === 'EAI_AGAIN') return `${host} could not be found`;
|
|
198
|
+
if (err.code === 'ETIMEDOUT' || err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH') return `${host} could not be reached`;
|
|
199
|
+
if (err.code === 'ECONNRESET') return `${url} closed the connection`;
|
|
200
|
+
return `${url}: ${err.message}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Does this kernel want a secret? Resolves true when an uncredentialed
|
|
205
|
+
* request is refused, false when it is served, and null when the question
|
|
206
|
+
* could not be asked at all.
|
|
207
|
+
*
|
|
208
|
+
* `/kernel/health` is the cheapest route behind the door. `/fs/health` is
|
|
209
|
+
* deliberately in front of it — a window must be able to find a kernel
|
|
210
|
+
* before it can talk to one — so the two together say both "something is
|
|
211
|
+
* there" and "it will want a password".
|
|
212
|
+
*/
|
|
213
|
+
export function guarded(url, { timeout = 4_000, pin = null } = {}) {
|
|
214
|
+
return new Promise(resolve => {
|
|
215
|
+
let settled = false;
|
|
216
|
+
const done = answer => { if (!settled) { settled = true; resolve(answer); } };
|
|
217
|
+
const request = ask(url, '/kernel/health', { timeout, pin }, response => {
|
|
218
|
+
response.resume();
|
|
219
|
+
/* a kernel too old to have the route answers 404, which is not a door */
|
|
220
|
+
done(response.statusCode === 401);
|
|
221
|
+
}, () => done(null)); /* a certificate we cannot vouch for answers nothing */
|
|
222
|
+
request?.on('timeout', () => { request.destroy(); done(null); });
|
|
223
|
+
request?.on('error', () => done(null));
|
|
224
|
+
});
|
|
225
|
+
}
|
package/main.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The anyOS client — a window on a kernel, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately the thinnest possible client. It contains no kernel,
|
|
5
|
+
* no filesystem and no agents: those live on a machine, and this opens one
|
|
6
|
+
* window on that machine's URL. Everything that matters — the wire, the
|
|
7
|
+
* apps, the agents — is exactly what a browser would get; the kernel cannot
|
|
8
|
+
* tell the difference. The desktop windows get no preload, no IPC, no
|
|
9
|
+
* nodeIntegration: the page talks to the kernel over HTTP like any client,
|
|
10
|
+
* so the renderer holds no powers a browser tab did not have.
|
|
11
|
+
*
|
|
12
|
+
* There is still a local machine, but it is found rather than contained. A
|
|
13
|
+
* kernel already answering here is connected to and left alone; a kernel
|
|
14
|
+
* merely installed here is started, and stopped again when its window
|
|
15
|
+
* closes. See local.js.
|
|
16
|
+
*
|
|
17
|
+
* The one page with a door into this process is the picker (connect.html):
|
|
18
|
+
* it lists machines and asks for one to be opened. Each machine opens once;
|
|
19
|
+
* asking again focuses its window.
|
|
20
|
+
*
|
|
21
|
+
* anyos the picker, then a desktop
|
|
22
|
+
* anyos --smoke open the local machine, verify, quit — for the suite
|
|
23
|
+
*/
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { app, BrowserWindow, Menu, ipcMain, dialog, safeStorage } from 'electron';
|
|
26
|
+
import { createMachines, LOCAL, reachable, guarded } from './machines.js';
|
|
27
|
+
import { readConnectLine } from './connect-line.js';
|
|
28
|
+
import { localKernel, startKernel, describe } from './local.js';
|
|
29
|
+
import { createSecrets, createKeyring, normalise } from './secrets.js';
|
|
30
|
+
import { installGuards } from './guard.js';
|
|
31
|
+
|
|
32
|
+
const smoke = process.argv.includes('--smoke');
|
|
33
|
+
const here = import.meta.dirname;
|
|
34
|
+
const darwin = process.platform === 'darwin';
|
|
35
|
+
|
|
36
|
+
/* the client's own memory — which machines it knows and the last one opened.
|
|
37
|
+
Not the state dir: that belongs to a kernel, and there is one per machine. */
|
|
38
|
+
const machines = createMachines(path.join(app.getPath('userData'), 'machines.json'));
|
|
39
|
+
|
|
40
|
+
/* the tokens for those machines, kept in their own 0600 file and sealed with
|
|
41
|
+
the OS keychain where there is one — see secrets.js */
|
|
42
|
+
const secrets = createSecrets(path.join(app.getPath('userData'), 'secrets.json'), { cipher: safeStorage });
|
|
43
|
+
|
|
44
|
+
/* what to answer a challenge with, per origin. A kernel we started put its
|
|
45
|
+
own minted token here and it never touches disk; a machine in the list
|
|
46
|
+
contributes the one that was typed for it. */
|
|
47
|
+
const keyring = createKeyring();
|
|
48
|
+
const offer = (url, token) => keyring.offer(url, token);
|
|
49
|
+
|
|
50
|
+
/* the two answers this client gives Chromium about a kernel — which
|
|
51
|
+
certificate it accepts and which secret it presents. See guard.js. */
|
|
52
|
+
installGuards(app, keyring);
|
|
53
|
+
|
|
54
|
+
let picker = null;
|
|
55
|
+
const windows = new Map(); /* machine id → its desktop window */
|
|
56
|
+
const stops = new Set(); /* kernels started here, for will-quit — one we merely found is not ours */
|
|
57
|
+
|
|
58
|
+
function desktopWindow(title) {
|
|
59
|
+
const win = new BrowserWindow({
|
|
60
|
+
width: 1280,
|
|
61
|
+
height: 800,
|
|
62
|
+
show: !smoke,
|
|
63
|
+
title,
|
|
64
|
+
backgroundColor: '#101014',
|
|
65
|
+
/* our shell draws its own menubar; keep the native controls as a slim
|
|
66
|
+
overlay rather than a title bar above a desktop */
|
|
67
|
+
titleBarStyle: darwin ? 'hiddenInset' : 'default',
|
|
68
|
+
webPreferences: { nodeIntegration: false, contextIsolation: true }
|
|
69
|
+
});
|
|
70
|
+
win.removeMenu?.();
|
|
71
|
+
return win;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* a window closes → whatever this client started for it stops with it */
|
|
75
|
+
function serve(win, stop) {
|
|
76
|
+
stops.add(stop);
|
|
77
|
+
win.on('closed', () => { stops.delete(stop); stop().catch(() => {}); });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* This computer. A kernel already listening is someone else's — very likely
|
|
82
|
+
* an `anyos` in a terminal — so the window points at it and closing changes
|
|
83
|
+
* nothing. One we start is ours, and stops with its window.
|
|
84
|
+
*/
|
|
85
|
+
async function openLocal() {
|
|
86
|
+
const local = await localKernel();
|
|
87
|
+
if (local.state === 'absent') {
|
|
88
|
+
throw new Error('anyos is not installed on this computer — install it, or add a machine to connect to');
|
|
89
|
+
}
|
|
90
|
+
if (local.state === 'running') {
|
|
91
|
+
/* not ours, so not our token either — whatever was typed for This
|
|
92
|
+
computer is all we have to offer it */
|
|
93
|
+
offer(local.url, secrets.get(LOCAL));
|
|
94
|
+
return { win: desktopWindow('anyOS'), url: local.url };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { url, stop, token } = await startKernel(local.kernel);
|
|
98
|
+
offer(url, token);
|
|
99
|
+
const win = desktopWindow('anyOS');
|
|
100
|
+
serve(win, stop);
|
|
101
|
+
return { win, url };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* A kernel we did not start and do not own: ask whether it is there, then
|
|
105
|
+
point a window at it. Closing the window leaves it running — its lifetime
|
|
106
|
+
is the owner's, like the forward that made it reachable. */
|
|
107
|
+
async function openAt(machine) {
|
|
108
|
+
const pin = secrets.pin(machine.id);
|
|
109
|
+
/* the window is told what to expect before it is pointed anywhere */
|
|
110
|
+
keyring.expect(machine.url, pin);
|
|
111
|
+
const problem = await reachable(machine.url, { pin });
|
|
112
|
+
if (problem) throw new Error(problem);
|
|
113
|
+
const token = secrets.get(machine.id);
|
|
114
|
+
if (!token && await guarded(machine.url, { pin })) {
|
|
115
|
+
throw new Error(`${machine.name} wants a token — Edit it and give it the kernel's ANYOS_TOKEN`);
|
|
116
|
+
}
|
|
117
|
+
offer(machine.url, token);
|
|
118
|
+
return { win: desktopWindow(`${machine.name} — anyOS`), url: machine.url };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function openMachine(id) {
|
|
122
|
+
const open = windows.get(id);
|
|
123
|
+
if (open && !open.isDestroyed()) { open.focus(); return open; }
|
|
124
|
+
const machine = machines.get(id);
|
|
125
|
+
if (!machine) throw new Error('that machine is no longer in the list');
|
|
126
|
+
const { win, url } = await (id === LOCAL ? openLocal() : openAt(machine));
|
|
127
|
+
windows.set(id, win);
|
|
128
|
+
win.on('closed', () => { if (windows.get(id) === win) windows.delete(id); });
|
|
129
|
+
machines.used(id);
|
|
130
|
+
win.loadURL(url).catch(() => {});
|
|
131
|
+
return win;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function showPicker(problem) {
|
|
135
|
+
if (picker && !picker.isDestroyed()) {
|
|
136
|
+
picker.focus();
|
|
137
|
+
if (problem) picker.webContents.send('machines:problem', problem);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
picker = new BrowserWindow({
|
|
141
|
+
width: 620,
|
|
142
|
+
height: 540,
|
|
143
|
+
resizable: false,
|
|
144
|
+
minimizable: false,
|
|
145
|
+
fullscreenable: false,
|
|
146
|
+
title: 'anyOS',
|
|
147
|
+
backgroundColor: '#0c1014',
|
|
148
|
+
titleBarStyle: darwin ? 'hiddenInset' : 'default',
|
|
149
|
+
webPreferences: { preload: path.join(here, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true }
|
|
150
|
+
});
|
|
151
|
+
picker.removeMenu?.();
|
|
152
|
+
picker.on('closed', () => { picker = null; });
|
|
153
|
+
if (problem) picker.webContents.once('did-finish-load', () => picker?.webContents.send('machines:problem', problem));
|
|
154
|
+
picker.loadFile(path.join(here, 'connect.html'));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* the picker's calls; each answers { ok } or { ok: false, error } — a sentence, never a stack */
|
|
158
|
+
const answer = fn => async (event, ...args) => {
|
|
159
|
+
try { return { ok: true, ...(await fn(event, ...args) ?? {}) }; } catch (err) { return { ok: false, error: err.message }; }
|
|
160
|
+
};
|
|
161
|
+
/* which build this is — the picker shows it, because "I pulled and still see
|
|
162
|
+
the old form" is otherwise unanswerable without a filesystem search */
|
|
163
|
+
ipcMain.handle('machines:version', answer(() => ({ version: app.getVersion(), from: here })));
|
|
164
|
+
ipcMain.handle('machines:list', answer(() => ({ machines: machines.list(), last: machines.last() })));
|
|
165
|
+
ipcMain.handle('machines:local', answer(async () => {
|
|
166
|
+
const local = await localKernel();
|
|
167
|
+
const token = secrets.get(LOCAL);
|
|
168
|
+
/* only worth asking of a kernel that is actually up; one we would start
|
|
169
|
+
ourselves gets a door we hold the key to */
|
|
170
|
+
const door = local.state === 'running' ? await guarded(local.url) === true : false;
|
|
171
|
+
return { local: { state: local.state, guarded: door, ...describe({ ...local, guarded: door, token }) } };
|
|
172
|
+
}));
|
|
173
|
+
/* a fingerprint that is not one would otherwise be normalised away to
|
|
174
|
+
nothing, quietly leaving the machine unpinned and unreachable for a reason
|
|
175
|
+
nobody could see */
|
|
176
|
+
function checkPin(fields) {
|
|
177
|
+
const given = String(fields?.fingerprint ?? '').trim();
|
|
178
|
+
if (given && !normalise(given)) {
|
|
179
|
+
throw Object.assign(new Error('that is not a certificate fingerprint — 64 hex characters, as the kernel printed it'), { status: 400 });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
ipcMain.handle('machines:add', answer((_event, fields) => {
|
|
183
|
+
checkPin(fields);
|
|
184
|
+
const machine = machines.add(fields);
|
|
185
|
+
secrets.set(machine.id, fields?.token);
|
|
186
|
+
secrets.setPin(machine.id, fields?.fingerprint);
|
|
187
|
+
return { machine };
|
|
188
|
+
}));
|
|
189
|
+
ipcMain.handle('machines:update', answer((_event, id, fields) => {
|
|
190
|
+
checkPin(fields);
|
|
191
|
+
const machine = machines.update(id, fields);
|
|
192
|
+
secrets.set(machine.id, fields?.token);
|
|
193
|
+
secrets.setPin(machine.id, fields?.fingerprint);
|
|
194
|
+
return { machine };
|
|
195
|
+
}));
|
|
196
|
+
/* This computer is not in the list and has nothing to edit but its token */
|
|
197
|
+
ipcMain.handle('machines:token', answer((_event, id, token) => { secrets.set(id, token); }));
|
|
198
|
+
/* the form prefills with the real secret, so leaving it blank forgets it */
|
|
199
|
+
ipcMain.handle('machines:secret', answer((_event, id) => ({
|
|
200
|
+
token: secrets.get(id) ?? '', fingerprint: secrets.pin(id) ?? '', sealed: secrets.sealed()
|
|
201
|
+
})));
|
|
202
|
+
ipcMain.handle('machines:remove', answer((_event, id) => { machines.remove(id); secrets.forget(id); }));
|
|
203
|
+
/**
|
|
204
|
+
* A connect line, pasted: the machine is saved and opened, with nothing to
|
|
205
|
+
* press in between.
|
|
206
|
+
*
|
|
207
|
+
* There is no review step because there would be nothing to review — none of
|
|
208
|
+
* these values were typed here, and nobody checks a 64-character fingerprint
|
|
209
|
+
* by eye. The form's fields remain for editing a machine later, which is a
|
|
210
|
+
* different job.
|
|
211
|
+
*
|
|
212
|
+
* Saved before connecting, always. A kernel that is down or a port the
|
|
213
|
+
* firewall has not opened yet should leave a machine in the list to retry,
|
|
214
|
+
* not send someone back to the terminal for the line again.
|
|
215
|
+
*/
|
|
216
|
+
ipcMain.handle('machines:connect', answer(async (_event, text) => {
|
|
217
|
+
const line = readConnectLine(text); /* throws a sentence of its own */
|
|
218
|
+
checkPin(line);
|
|
219
|
+
const existing = machines.byUrl(line.url);
|
|
220
|
+
/* the one case where a paste destroys something: an address already in the
|
|
221
|
+
list keeps its name, but its token and its certificate are replaced */
|
|
222
|
+
if (existing && !await confirmReplace(existing)) return { cancelled: true };
|
|
223
|
+
|
|
224
|
+
const machine = existing
|
|
225
|
+
? machines.update(existing.id, { name: existing.name, url: line.url })
|
|
226
|
+
: machines.add({ name: line.name, url: line.url });
|
|
227
|
+
secrets.set(machine.id, line.token);
|
|
228
|
+
secrets.setPin(machine.id, line.fingerprint);
|
|
229
|
+
|
|
230
|
+
await openMachine(machine.id);
|
|
231
|
+
picker?.close();
|
|
232
|
+
return { machine };
|
|
233
|
+
}));
|
|
234
|
+
|
|
235
|
+
async function confirmReplace(machine) {
|
|
236
|
+
const { response } = await dialog.showMessageBox(picker ?? null, {
|
|
237
|
+
type: 'warning',
|
|
238
|
+
buttons: ['Replace', 'Cancel'],
|
|
239
|
+
defaultId: 0,
|
|
240
|
+
cancelId: 1,
|
|
241
|
+
message: `Replace the credentials for ${machine.name}?`,
|
|
242
|
+
detail: `${machine.url} is already in the list. This connect line replaces its token and the certificate it pins.`
|
|
243
|
+
});
|
|
244
|
+
return response === 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ipcMain.handle('machines:open', answer(async (_event, id) => {
|
|
248
|
+
await openMachine(id);
|
|
249
|
+
picker?.close();
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
const menu = Menu.buildFromTemplate([
|
|
253
|
+
...(darwin ? [{ role: 'appMenu' }] : []),
|
|
254
|
+
{
|
|
255
|
+
label: 'Machine',
|
|
256
|
+
submenu: [
|
|
257
|
+
{ label: 'Connect to…', accelerator: 'CmdOrCtrl+Shift+O', click: () => showPicker() },
|
|
258
|
+
{ type: 'separator' },
|
|
259
|
+
{ role: 'close' }
|
|
260
|
+
]
|
|
261
|
+
},
|
|
262
|
+
{ role: 'editMenu' },
|
|
263
|
+
{ role: 'windowMenu' }
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
app.whenReady().then(async () => {
|
|
267
|
+
Menu.setApplicationMenu(menu);
|
|
268
|
+
if (!smoke) return showPicker();
|
|
269
|
+
|
|
270
|
+
const win = await openMachine(LOCAL);
|
|
271
|
+
win.webContents.once('did-finish-load', async () => {
|
|
272
|
+
const title = await win.webContents.executeJavaScript('document.title');
|
|
273
|
+
/* the desktop mounts asynchronously after load — give it a beat */
|
|
274
|
+
const shell = await win.webContents.executeJavaScript(`new Promise(done => setTimeout(() => {
|
|
275
|
+
const bar = document.querySelector('#menubar');
|
|
276
|
+
done(document.documentElement.className + ' · menubar ' + (bar
|
|
277
|
+
? 'pad ' + getComputedStyle(bar).paddingLeft : 'not mounted'));
|
|
278
|
+
}, 800))`);
|
|
279
|
+
console.log(`[client] loaded ${win.webContents.getURL()} — title "${title}" · ${shell}`);
|
|
280
|
+
app.quit();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/* an OS quits when its window closes, on every platform — there is no
|
|
285
|
+
sensible dockless background state for a desktop */
|
|
286
|
+
app.on('window-all-closed', () => app.quit());
|
|
287
|
+
app.on('will-quit', () => { for (const stop of stops) stop().catch(() => {}); });
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "anyos-client",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "The anyOS thin client \u2014 one window on a kernel, here or on another machine",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "main.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"anyos": "bin/anyos.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"main.js",
|
|
14
|
+
"local.js",
|
|
15
|
+
"machines.js",
|
|
16
|
+
"secrets.js",
|
|
17
|
+
"guard.js",
|
|
18
|
+
"preload.cjs",
|
|
19
|
+
"connect.html",
|
|
20
|
+
"connect.css",
|
|
21
|
+
"connect.js",
|
|
22
|
+
"connect-line.js",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"electron": "^44.0.0"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"start": "electron .",
|
|
34
|
+
"smoke": "electron . --smoke",
|
|
35
|
+
"test": "node test/machines.mjs && node test/secrets.mjs && node test/connect-line.mjs && node test/local.mjs && node test/electron.mjs",
|
|
36
|
+
"lint": "node test/boundaries.mjs"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/preload.cjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* The picker's one door to the main process: a handful of calls about
|
|
2
|
+
machines, each answered with { ok } or { ok: false, error }. The desktop
|
|
3
|
+
windows get no preload at all. */
|
|
4
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
5
|
+
|
|
6
|
+
contextBridge.exposeInMainWorld('machines', {
|
|
7
|
+
version: () => ipcRenderer.invoke('machines:version'),
|
|
8
|
+
list: () => ipcRenderer.invoke('machines:list'),
|
|
9
|
+
local: () => ipcRenderer.invoke('machines:local'),
|
|
10
|
+
add: fields => ipcRenderer.invoke('machines:add', fields),
|
|
11
|
+
update: (id, fields) => ipcRenderer.invoke('machines:update', id, fields),
|
|
12
|
+
remove: id => ipcRenderer.invoke('machines:remove', id),
|
|
13
|
+
secret: id => ipcRenderer.invoke('machines:secret', id),
|
|
14
|
+
token: (id, token) => ipcRenderer.invoke('machines:token', id, token),
|
|
15
|
+
open: id => ipcRenderer.invoke('machines:open', id),
|
|
16
|
+
connect: line => ipcRenderer.invoke('machines:connect', line),
|
|
17
|
+
onProblem: handler => { ipcRenderer.on('machines:problem', (_event, text) => handler(String(text))); }
|
|
18
|
+
});
|