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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xuheng Li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# anyos-client
|
|
2
|
+
|
|
3
|
+
One window on an [anyOS](https://github.com/pid1labs/anyos) kernel — here, or
|
|
4
|
+
on another machine.
|
|
5
|
+
|
|
6
|
+
The client contains no kernel, no filesystem and no agents. Those live on a
|
|
7
|
+
machine; this opens a window on that machine's URL and gets exactly what a
|
|
8
|
+
browser would get. The desktop page runs with no preload, no IPC and no node
|
|
9
|
+
integration, so it holds no powers a browser tab did not have.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g anyos-client
|
|
13
|
+
anyos
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Its one dependency is Electron, which brings its own binary.
|
|
17
|
+
|
|
18
|
+
## The picker
|
|
19
|
+
|
|
20
|
+
`anyos` opens a list of machines. `⌘⇧O` brings it back.
|
|
21
|
+
|
|
22
|
+
**This computer** is found rather than configured, and reads as one of three
|
|
23
|
+
things:
|
|
24
|
+
|
|
25
|
+
| | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| a kernel is running at 127.0.0.1:8765 | opening connects to it |
|
|
28
|
+
| anyos is installed here — opening starts it | opening runs `anyos`, and closing the window stops it |
|
|
29
|
+
| anyos is not installed on this computer | there is no local machine; add an address |
|
|
30
|
+
|
|
31
|
+
A kernel that was already running is not the client's, so closing its window
|
|
32
|
+
leaves it running. One the client started is, and stops with its window.
|
|
33
|
+
|
|
34
|
+
To have a local kernel, install one:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm install -g anyos
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The client looks for it in `$ANYOS_KERNEL` (a command, or a path to the
|
|
41
|
+
kernel's bin), then `anyos` on `PATH`, then an `anyos` package installed
|
|
42
|
+
alongside this one. The kernel was called `webos` before it was called
|
|
43
|
+
`anyos`, so each of those falls back to the old name — a machine that has not
|
|
44
|
+
pulled the rename still starts, and `$WEBOS_KERNEL` is still read.
|
|
45
|
+
|
|
46
|
+
## Tokens
|
|
47
|
+
|
|
48
|
+
A kernel started with `ANYOS_TOKEN` set costs that secret on every route. The
|
|
49
|
+
client answers for you.
|
|
50
|
+
|
|
51
|
+
The scheme is HTTP Basic, and that is the point: app code loaded by
|
|
52
|
+
`import()`, the event stream and the first navigation cannot set a header of
|
|
53
|
+
their own, but Chromium attaches a Basic credential to all of them once it
|
|
54
|
+
has one. So the whole of authentication is one handler in the main process,
|
|
55
|
+
and no token is threaded through any request.
|
|
56
|
+
|
|
57
|
+
A kernel **this client starts** gets a fresh 32-byte secret per launch, held
|
|
58
|
+
in the two live processes and never written down. Nothing to configure, and
|
|
59
|
+
nothing on disk to leak. If `ANYOS_TOKEN` is already set in your environment,
|
|
60
|
+
that one is inherited instead — your kernel, your key.
|
|
61
|
+
|
|
62
|
+
A kernel **already running** is not ours and its secret is not either. If it
|
|
63
|
+
wants one, the local row says so and offers **Token**. For another machine,
|
|
64
|
+
the token goes in its **Edit** form. What is stored is what the field shows,
|
|
65
|
+
so clearing the field forgets it.
|
|
66
|
+
|
|
67
|
+
Tokens live in `secrets.json` beside the machine list, never in
|
|
68
|
+
`machines.json` — that file stays a plain document. Where the OS has a
|
|
69
|
+
keychain, Electron's `safeStorage` seals each value and what lands on disk is
|
|
70
|
+
ciphertext; where it does not, the value is written as it is and the record
|
|
71
|
+
says `"how": "plain"` rather than implying otherwise. The file is `0600`
|
|
72
|
+
either way.
|
|
73
|
+
|
|
74
|
+
Over `http` the credential travels in the clear, so the token is not what
|
|
75
|
+
makes a remote kernel safe to reach — the forward is, or the certificate
|
|
76
|
+
below.
|
|
77
|
+
|
|
78
|
+
## Certificates
|
|
79
|
+
|
|
80
|
+
A kernel started with `--tls-cert` and `--tls-key` serves HTTPS, and its
|
|
81
|
+
certificate is self-signed. There is no authority to ask about it, so this
|
|
82
|
+
client asks nothing: it pins one fingerprint per machine and accepts that
|
|
83
|
+
certificate and no other.
|
|
84
|
+
|
|
85
|
+
That is a stronger claim than a public CA would give, not a weaker one. A
|
|
86
|
+
certificate signed by an authority means *someone vouched for this name*; a
|
|
87
|
+
pin means *this exact key, or nothing*. It also frees a kernel from having to
|
|
88
|
+
match a name, so it can live at a bare IP that changes.
|
|
89
|
+
|
|
90
|
+
The kernel prints the fingerprint when it starts:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
cert 50:CE:50:AE:30:01:DF:13:…
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Put that in the machine's **Certificate** field. Colons and case are ignored,
|
|
97
|
+
so paste it however it came. If the certificate ever stops matching, the
|
|
98
|
+
connection stops with it — a machine whose key changed is treated as a
|
|
99
|
+
different machine, which is the entire point.
|
|
100
|
+
|
|
101
|
+
**A new machine's fingerprint has to be entered by hand.** The friendlier
|
|
102
|
+
alternative — accept whatever certificate appears the first time, pin it, and
|
|
103
|
+
complain only if it later changes — was not taken. It is exactly the first
|
|
104
|
+
connection that a pin is supposed to protect, and trusting it because it came
|
|
105
|
+
first protects every connection except the one that mattered. You are already
|
|
106
|
+
reading the kernel's startup output to copy its token; the fingerprint is
|
|
107
|
+
printed on the line below it.
|
|
108
|
+
|
|
109
|
+
Pins live beside the tokens in `secrets.json`, written plain, because a
|
|
110
|
+
fingerprint is not a secret — the kernel prints it for anyone to read. It is
|
|
111
|
+
kept there rather than in `machines.json` because quietly editing a pin is an
|
|
112
|
+
attack, and that file is a document people are told they may edit.
|
|
113
|
+
|
|
114
|
+
`https` changes nothing else. The token still travels as HTTP Basic, the door
|
|
115
|
+
still answers 401 without it, and `app.on('login')` is untouched — TLS and the
|
|
116
|
+
door compose rather than replacing one another.
|
|
117
|
+
|
|
118
|
+
Loopback stays HTTP. A kernel on this computer, whether found or started
|
|
119
|
+
here, gains nothing from encrypting a conversation that never leaves the
|
|
120
|
+
machine.
|
|
121
|
+
|
|
122
|
+
## One line instead of three fields
|
|
123
|
+
|
|
124
|
+
A machine takes an address, a token and a fingerprint. The kernel's
|
|
125
|
+
`remote.sh` prints all three as a single line:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
anyos://eyJ2IjoxLCJuYW1lIjoiY2xhdy1zZXJ2ZXIiLCJhZGRyZXNz…
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Paste it into the picker and it connects.** Anywhere in the window — the
|
|
132
|
+
address box, or just at the list. There is no form to fill and nothing to
|
|
133
|
+
press: the machine is saved, named after its host, and opened.
|
|
134
|
+
|
|
135
|
+
There is no review step because there would be nothing to review. None of
|
|
136
|
+
those values were typed here, so none of them can have been mistyped, and a
|
|
137
|
+
64-character fingerprint is not something anyone checks by eye.
|
|
138
|
+
|
|
139
|
+
The machine is saved *before* the connection is tried, so a kernel that is
|
|
140
|
+
down or a port the firewall has not opened yet leaves an entry in the list to
|
|
141
|
+
retry — rather than sending you back to the terminal for the line again.
|
|
142
|
+
|
|
143
|
+
Pasting a line for an address already in the list replaces its token and its
|
|
144
|
+
pinned certificate, and asks first, because that is the one case where a paste
|
|
145
|
+
destroys something.
|
|
146
|
+
|
|
147
|
+
A line that is damaged, or of a version this client does not know, says so and
|
|
148
|
+
changes nothing.
|
|
149
|
+
|
|
150
|
+
> **The line contains the token, so the line is a secret.** Typing the token
|
|
151
|
+
> into its own labelled box made that obvious; one opaque blob does not. Treat
|
|
152
|
+
> it exactly as you would the token: not in a chat log, not in a ticket, not
|
|
153
|
+
> in a screenshot.
|
|
154
|
+
|
|
155
|
+
**Add a machine…** asks for a name and an address, and nothing else. A kernel
|
|
156
|
+
reached by hand is one on a forward, and a forward needs neither a token nor a
|
|
157
|
+
certificate. Anything that does have credentials arrives by pasting its line.
|
|
158
|
+
|
|
159
|
+
Editing a machine you already have shows its token and certificate too, because
|
|
160
|
+
replacing a token after `./remote.sh renew` is a real job and that is where it
|
|
161
|
+
is done.
|
|
162
|
+
|
|
163
|
+
## Another machine
|
|
164
|
+
|
|
165
|
+
Install the kernel on that machine — it runs no window, so it needs neither
|
|
166
|
+
Electron nor this package. Start it there (`anyos`).
|
|
167
|
+
|
|
168
|
+
The kernel binds loopback and asks no caller who they are, so it is never put
|
|
169
|
+
on a network directly. Bring it to this machine over a forward you open
|
|
170
|
+
yourself:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
ssh -N -L 8765:127.0.0.1:8765 user@host
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Then **Add a machine…** with the address the forward made local —
|
|
177
|
+
`localhost:8765` — and its token if that kernel was started with one. Open
|
|
178
|
+
it.
|
|
179
|
+
|
|
180
|
+
A kernel serving HTTPS can be reached directly instead, at `https://host:8765`
|
|
181
|
+
with its fingerprint pinned, and then the certificate is what protects the
|
|
182
|
+
credential rather than the forward. The desktop you see is that machine's, with
|
|
183
|
+
its files and its agents. The client does not start or stop that kernel;
|
|
184
|
+
closing the window leaves it running, and the connection ends when you end the
|
|
185
|
+
forward.
|
|
186
|
+
|
|
187
|
+
The list of machines is the only thing the client remembers. It lives in
|
|
188
|
+
Electron's user-data directory as `machines.json`.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
npm install
|
|
194
|
+
npm test # the machine list, the address, the local kernel, the handlers
|
|
195
|
+
npm run lint # that this package reaches nothing outside itself
|
|
196
|
+
npm start
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The last of those tests runs the certificate and login handlers on a real
|
|
200
|
+
Electron against a real TLS server, including the cases where they must
|
|
201
|
+
refuse — a pin never seen to reject proves nothing. It needs Electron's
|
|
202
|
+
binary, which is a separate download (`node node_modules/electron/install.js`),
|
|
203
|
+
a display (`xvfb-run` will do), and `openssl` to mint a certificate. Missing
|
|
204
|
+
any of those it says which and skips, rather than passing quietly.
|
|
205
|
+
|
|
206
|
+
To run against a kernel you are working on rather than an installed one, name
|
|
207
|
+
it — the client spawns a command, so any checkout will do:
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
ANYOS_KERNEL=../anyos/bin/anyos.js npm start
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`npm run lint` is the rule this package is built on: every import is a Node
|
|
214
|
+
builtin, `electron`, or a relative path that stays inside this directory, and
|
|
215
|
+
the picker links no stylesheet it does not own. The client must keep working
|
|
216
|
+
on a machine with no kernel on it at all.
|
|
217
|
+
|
|
218
|
+
MIT.
|
package/bin/anyos.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* anyos — open the client.
|
|
4
|
+
*
|
|
5
|
+
* Electron's package exports the path to its own binary when it is required
|
|
6
|
+
* from Node, so this is the whole launcher: run that binary on this package
|
|
7
|
+
* and pass along whatever was asked for.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const electron = createRequire(import.meta.url)('electron');
|
|
14
|
+
const client = path.resolve(import.meta.dirname, '..');
|
|
15
|
+
|
|
16
|
+
const child = spawn(electron, [client, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
17
|
+
child.on('error', err => { console.error(err.message); process.exit(1); });
|
|
18
|
+
child.on('close', code => process.exit(code ?? 0));
|
package/connect-line.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connect line: one string carrying everything needed to reach a machine.
|
|
3
|
+
*
|
|
4
|
+
* anyos://<base64url of {v,name,address,fingerprint,token}>
|
|
5
|
+
*
|
|
6
|
+
* A machine takes three values, and three values typed into three boxes is
|
|
7
|
+
* three chances to put the right one in the wrong box — after which the only
|
|
8
|
+
* report is that it did not connect. The kernel's remote.sh prints this
|
|
9
|
+
* instead, and pasting it fills the form in, visibly, so what arrived can
|
|
10
|
+
* still be read and corrected before it is saved.
|
|
11
|
+
*
|
|
12
|
+
* It carries the token, so it is as secret as the token is.
|
|
13
|
+
*
|
|
14
|
+
* This file imports nothing. It is the one module the picker's page and the
|
|
15
|
+
* main process both run, and a page cannot have node: builtins.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const PREFIX = 'anyos://';
|
|
19
|
+
|
|
20
|
+
/** Is this a connect line rather than an address someone is typing? */
|
|
21
|
+
export const isConnectLine = text => String(text ?? '').trim().startsWith(PREFIX);
|
|
22
|
+
|
|
23
|
+
const oops = message => Object.assign(new Error(message), { status: 400 });
|
|
24
|
+
|
|
25
|
+
/* base64url, unpadded, and possibly with a newline where a terminal wrapped
|
|
26
|
+
it — the whitespace goes before anything else looks at it */
|
|
27
|
+
function decode(body) {
|
|
28
|
+
const b64 = body.replace(/\s+/g, '').replaceAll('-', '+').replaceAll('_', '/');
|
|
29
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
30
|
+
const binary = atob(padded);
|
|
31
|
+
const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
|
|
32
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read one, or throw a sentence saying what is wrong with it. Returns the
|
|
37
|
+
* fields in the names the form uses, so it drops straight into the picker:
|
|
38
|
+
* { name, url, fingerprint, token }.
|
|
39
|
+
*
|
|
40
|
+
* Nothing here is trusted for being inside a connect line — the address is
|
|
41
|
+
* still cleaned, the fingerprint still has to be one, and a version this
|
|
42
|
+
* client does not know is refused rather than guessed at.
|
|
43
|
+
*/
|
|
44
|
+
export function readConnectLine(text) {
|
|
45
|
+
const raw = String(text ?? '').trim();
|
|
46
|
+
if (!raw.startsWith(PREFIX)) throw oops('that is not a connect line');
|
|
47
|
+
/* the prefix comes off as a string. It is not a URL: the payload is
|
|
48
|
+
base64url, whose alphabet has `_` in it, which is not a legal hostname
|
|
49
|
+
and which a URL parser would mangle or refuse. */
|
|
50
|
+
const body = raw.slice(PREFIX.length).trim();
|
|
51
|
+
if (!body) throw oops('that connect line is empty');
|
|
52
|
+
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(decode(body));
|
|
56
|
+
} catch {
|
|
57
|
+
throw oops('that connect line is damaged — copy the whole of it');
|
|
58
|
+
}
|
|
59
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
60
|
+
throw oops('that connect line is damaged — copy the whole of it');
|
|
61
|
+
}
|
|
62
|
+
if (parsed.v !== 1) {
|
|
63
|
+
throw oops(`that connect line is version ${JSON.stringify(parsed.v) ?? 'unknown'}, which this client does not know — update it`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const address = String(parsed.address ?? '').trim();
|
|
67
|
+
if (!address) throw oops('that connect line carries no address');
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: String(parsed.name ?? '').trim(),
|
|
71
|
+
url: address,
|
|
72
|
+
fingerprint: String(parsed.fingerprint ?? '').trim(),
|
|
73
|
+
token: String(parsed.token ?? '').trim()
|
|
74
|
+
};
|
|
75
|
+
}
|
package/connect.css
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/* The picker: one small window in the desktop's own vocabulary — panels on
|
|
2
|
+
hairlines, no radii, the phosphor for what is live.
|
|
3
|
+
|
|
4
|
+
The tokens and the handful of controls are carried here rather than linked
|
|
5
|
+
out of the desktop's stylesheet: this package ships without a kernel, so
|
|
6
|
+
there is no web/ to reach into. It is a copy of a shared language, not a
|
|
7
|
+
dependency on one — the picker is the only page the client ever draws, and
|
|
8
|
+
it is drawn dark, because the window it opens onto is.
|
|
9
|
+
|
|
10
|
+
The type runs a step above the desktop's. A desktop is read at arm's length
|
|
11
|
+
with a window full of context around every word; this is one window on an
|
|
12
|
+
empty screen, read once, and its smallest words — an address, a field label
|
|
13
|
+
— are exactly the ones that must not be guessed at. Hairlines stay 1px:
|
|
14
|
+
what grows is the reading, not the frame. */
|
|
15
|
+
|
|
16
|
+
:root {
|
|
17
|
+
--font: "Instrument Sans", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", system-ui, sans-serif;
|
|
18
|
+
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
19
|
+
|
|
20
|
+
--ground: #0c1014;
|
|
21
|
+
--panel: #10161b;
|
|
22
|
+
--raised: #172029;
|
|
23
|
+
--hair: #223039;
|
|
24
|
+
--hair-soft: #1a242c;
|
|
25
|
+
--text: #d7e0e6;
|
|
26
|
+
--text-2: #a9b7c0;
|
|
27
|
+
--text-3: #7d8d98;
|
|
28
|
+
--live: #3ddc97;
|
|
29
|
+
--bad: #ff6b57;
|
|
30
|
+
--accent-text: #0c1014;
|
|
31
|
+
--field: var(--ground);
|
|
32
|
+
--field-border: var(--hair);
|
|
33
|
+
--scroll: rgba(215,224,230,.22);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
* { box-sizing: border-box; }
|
|
37
|
+
[hidden] { display: none !important; }
|
|
38
|
+
html, body { height: 100%; margin: 0; background: var(--ground); }
|
|
39
|
+
|
|
40
|
+
body {
|
|
41
|
+
font-family: var(--font);
|
|
42
|
+
font-size: 13px;
|
|
43
|
+
color: var(--text);
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
-webkit-font-smoothing: antialiased;
|
|
46
|
+
user-select: none;
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
button { font: inherit; color: inherit; background: none; border: 0; cursor: default; }
|
|
51
|
+
input { font: inherit; color: inherit; }
|
|
52
|
+
::selection { background: color-mix(in srgb, var(--live) 30%, transparent); }
|
|
53
|
+
|
|
54
|
+
* { scrollbar-width: thin; scrollbar-color: var(--scroll) transparent; }
|
|
55
|
+
*::-webkit-scrollbar { width: 9px; }
|
|
56
|
+
*::-webkit-scrollbar-thumb { background: var(--scroll); border: 3px solid transparent; background-clip: content-box; }
|
|
57
|
+
*::-webkit-scrollbar-track { background: transparent; }
|
|
58
|
+
|
|
59
|
+
/* the shared vocabulary, as much of it as one window needs */
|
|
60
|
+
.spec-heading { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -.01em; line-height: 1.25; }
|
|
61
|
+
.spec-text { margin: 0; line-height: 1.6; font-size: 14px; max-width: 62ch; }
|
|
62
|
+
.spec-text.muted { color: var(--text-3); font-size: 13px; }
|
|
63
|
+
.spec-item-title { font-size: 13px; font-weight: 500; }
|
|
64
|
+
.spec-item-detail { font-size: 12px; color: var(--text-3); }
|
|
65
|
+
|
|
66
|
+
/* a button says exactly what it does; one primary per screen, in phosphor */
|
|
67
|
+
.btn {
|
|
68
|
+
height: 28px; padding: 0 12px; font-family: var(--mono); font-size: 11px;
|
|
69
|
+
color: var(--text); background: var(--panel); border: 1px solid var(--hair);
|
|
70
|
+
}
|
|
71
|
+
.btn:hover { background: var(--raised); }
|
|
72
|
+
.btn:active { background: var(--live); color: var(--accent-text); border-color: var(--live); }
|
|
73
|
+
.btn.primary { color: var(--live); border-color: var(--live); }
|
|
74
|
+
.btn.primary:active { background: var(--live); color: var(--accent-text); }
|
|
75
|
+
.btn:disabled { color: var(--text-3); border-color: var(--hair-soft); background: var(--panel); }
|
|
76
|
+
|
|
77
|
+
/* a ground-coloured well; focus draws the hairline in phosphor */
|
|
78
|
+
.field {
|
|
79
|
+
height: 28px; padding: 0 10px; outline: none; font-family: var(--mono); font-size: 12px;
|
|
80
|
+
color: var(--text); background: var(--field); border: 1px solid var(--field-border); user-select: text;
|
|
81
|
+
}
|
|
82
|
+
.field::placeholder { color: var(--text-3); }
|
|
83
|
+
.field:focus { border-color: var(--live); }
|
|
84
|
+
|
|
85
|
+
.vibe-error { color: var(--bad); font-size: 12px; font-family: var(--mono); white-space: pre-wrap; }
|
|
86
|
+
/* the same line also reports something going right — a pasted connect line */
|
|
87
|
+
.vibe-error.good { color: var(--live); }
|
|
88
|
+
|
|
89
|
+
/* the picker itself ------------------------------------------ */
|
|
90
|
+
.picker { height: 100%; display: flex; flex-direction: column; gap: 16px; padding: 0 28px 20px; }
|
|
91
|
+
|
|
92
|
+
/* hiddenInset puts the traffic lights top-left and leaves no title bar, so the
|
|
93
|
+
header is the handle that moves the window — and it takes the strip above it
|
|
94
|
+
too, which is the first place a hand reaches for and was padding before */
|
|
95
|
+
.drag { -webkit-app-region: drag; padding-top: 40px; }
|
|
96
|
+
.drag .spec-heading { margin: 0; font-size: 23px; }
|
|
97
|
+
.drag .spec-text { margin-top: 4px; font-size: 14px; }
|
|
98
|
+
/* small, and always there: the answer to "which build am I looking at" */
|
|
99
|
+
.build { margin: 6px 0 0; font-family: var(--mono); font-size: 11px; color: var(--text-2); opacity: .7; }
|
|
100
|
+
|
|
101
|
+
.machines { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
102
|
+
.list { list-style: none; margin: 0; padding: 0; overflow: auto; border: 1px solid var(--hair); background: var(--panel); }
|
|
103
|
+
.machine {
|
|
104
|
+
display: flex; align-items: center; gap: 12px; padding: 10px 12px;
|
|
105
|
+
border-bottom: 1px solid var(--hair-soft); outline: none;
|
|
106
|
+
}
|
|
107
|
+
.machine:last-child { border-bottom: 0; }
|
|
108
|
+
.machine:hover { background: var(--raised); }
|
|
109
|
+
.machine.selected { background: var(--raised); box-shadow: inset 2px 0 0 var(--live); }
|
|
110
|
+
.machine .glyph { display: inline-flex; color: var(--text-3); }
|
|
111
|
+
.machine.selected .glyph { color: var(--live); }
|
|
112
|
+
.machine .spec-item-title { font-size: 15px; }
|
|
113
|
+
.machine .text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
114
|
+
.machine .spec-item-detail { font-family: var(--mono); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
115
|
+
.machine .tools { display: none; gap: 12px; }
|
|
116
|
+
.machine:hover .tools, .machine.selected .tools { display: inline-flex; }
|
|
117
|
+
|
|
118
|
+
/* what this computer has: a kernel answering, or none to answer */
|
|
119
|
+
.spec-item-detail.tone-good { color: var(--live); }
|
|
120
|
+
.spec-item-detail.tone-bad { color: var(--bad); }
|
|
121
|
+
.machine.unavailable .spec-item-title, .machine.unavailable .glyph { color: var(--text-3); }
|
|
122
|
+
|
|
123
|
+
.link { padding: 0; font-family: var(--mono); font-size: 13px; color: var(--text-2); }
|
|
124
|
+
.link:hover { color: var(--text); }
|
|
125
|
+
|
|
126
|
+
/* the controls, a step up with them — a taller well for a taller word */
|
|
127
|
+
.picker .field { height: 32px; font-size: 14px; }
|
|
128
|
+
.picker .btn { height: 32px; padding: 0 14px; font-size: 13px; }
|
|
129
|
+
|
|
130
|
+
.editor {
|
|
131
|
+
display: grid; grid-template-columns: 84px 1fr; gap: 10px 12px; align-items: center;
|
|
132
|
+
padding: 14px 12px; border: 1px solid var(--hair); background: var(--panel);
|
|
133
|
+
}
|
|
134
|
+
.editor label { display: contents; }
|
|
135
|
+
/* the two groups that hide together are not boxes: they hand their fields
|
|
136
|
+
straight to the grid, or a wrapper would break the label/field columns */
|
|
137
|
+
.editor #address, .editor #credentials, .editor #certificate { display: contents; }
|
|
138
|
+
.editor #address[hidden], .editor #credentials[hidden], .editor #certificate[hidden] { display: none; }
|
|
139
|
+
.editor label > span { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-2); }
|
|
140
|
+
.editor .field { width: 100%; }
|
|
141
|
+
.editor .note { grid-column: 2; margin: 0; font-size: 13px; }
|
|
142
|
+
.editor .actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; align-items: center; gap: 16px; margin-top: 4px; }
|
|
143
|
+
|
|
144
|
+
/* While the form is open it is the whole window: the list gives way to it.
|
|
145
|
+
The window cannot be resized and the body does not scroll, so a form that
|
|
146
|
+
outgrew the viewport would put Save where no one could reach it. The error
|
|
147
|
+
line stays, because that is where a rejected address explains itself. */
|
|
148
|
+
.picker:has(#form:not([hidden])) #list,
|
|
149
|
+
.picker:has(#form:not([hidden])) .build { display: none; }
|
|
150
|
+
|
|
151
|
+
footer { display: flex; justify-content: space-between; align-items: center; }
|
|
152
|
+
.vibe-error { margin: 10px 0 0; font-size: 13px; }
|
|
153
|
+
|
|
154
|
+
/* busy is shown, never said: the rows dim, a phosphor hairline crawls along the top of the list */
|
|
155
|
+
.machines.busy .machine { opacity: .55; pointer-events: none; }
|
|
156
|
+
.machines.busy::before {
|
|
157
|
+
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; z-index: 1;
|
|
158
|
+
background: linear-gradient(90deg, transparent, var(--live) 40%, var(--live) 60%, transparent);
|
|
159
|
+
background-size: 50% 100%; background-repeat: no-repeat;
|
|
160
|
+
animation: vibe-crawl 1.8s ease-in-out infinite alternate;
|
|
161
|
+
}
|
|
162
|
+
@keyframes vibe-crawl { from { background-position: -100% 0; } to { background-position: 200% 0; } }
|
|
163
|
+
|
|
164
|
+
@media (prefers-reduced-motion: reduce) {
|
|
165
|
+
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
|
166
|
+
}
|
package/connect.html
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<!-- This page holds the only door into the main process, so it is told exactly
|
|
6
|
+
what it may load: its own script and style, the one font service, and
|
|
7
|
+
nothing else at all. It has no inline script or style to allow. -->
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; form-action 'none'; base-uri 'none'">
|
|
9
|
+
<title>anyOS</title>
|
|
10
|
+
<!-- the desktop's vocabulary, carried in connect.css: this package ships no web/ -->
|
|
11
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap">
|
|
12
|
+
<link rel="stylesheet" href="connect.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main class="picker">
|
|
16
|
+
<header class="drag">
|
|
17
|
+
<h1 class="spec-heading">Open a machine</h1>
|
|
18
|
+
<p class="spec-text muted">The desktop, its files and its agents live where the kernel runs.</p>
|
|
19
|
+
<p class="build" id="build" title="which copy of the client this window is"></p>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<section class="machines" id="machines">
|
|
23
|
+
<ol class="list" id="list"></ol>
|
|
24
|
+
<p class="vibe-error" id="problem" hidden></p>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<form class="editor" id="form" hidden>
|
|
28
|
+
<div id="address">
|
|
29
|
+
<label><span>Name</span><input class="field" name="name" placeholder="devbox" autocomplete="off" spellcheck="false"></label>
|
|
30
|
+
<label><span>Address</span><input class="field" name="url" placeholder="localhost:8765" autocomplete="off" spellcheck="false" required></label>
|
|
31
|
+
</div>
|
|
32
|
+
<div id="credentials">
|
|
33
|
+
<label><span>Token</span><input class="field" name="token" type="password" placeholder="only if that kernel asks for one" autocomplete="off" spellcheck="false"></label>
|
|
34
|
+
<p class="spec-text muted note" id="token-note">The kernel's <code>ANYOS_TOKEN</code>, if it was started with one. Empty forgets it.</p>
|
|
35
|
+
</div>
|
|
36
|
+
<div id="certificate">
|
|
37
|
+
<label><span>Certificate</span><input class="field" name="fingerprint" placeholder="only for an https address" autocomplete="off" spellcheck="false"></label>
|
|
38
|
+
<p class="spec-text muted note" id="cert-note">For an <code>https://</code> address, the fingerprint its kernel printed — <code>cert 50:CE:…</code>. Nothing else is accepted.</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="actions">
|
|
41
|
+
<button type="button" class="link" id="cancel">Cancel</button>
|
|
42
|
+
<button type="submit" class="btn primary">Save</button>
|
|
43
|
+
</div>
|
|
44
|
+
</form>
|
|
45
|
+
|
|
46
|
+
<footer id="footer">
|
|
47
|
+
<button type="button" class="link" id="add">Add a machine…</button>
|
|
48
|
+
<button type="button" class="btn primary" id="open" disabled>Open</button>
|
|
49
|
+
</footer>
|
|
50
|
+
</main>
|
|
51
|
+
|
|
52
|
+
<template id="row">
|
|
53
|
+
<li class="machine" tabindex="-1">
|
|
54
|
+
<span class="glyph"></span>
|
|
55
|
+
<span class="text"><span class="spec-item-title"></span><span class="spec-item-detail"></span></span>
|
|
56
|
+
<span class="tools">
|
|
57
|
+
<button type="button" class="link token">Token</button>
|
|
58
|
+
<button type="button" class="link edit">Edit</button>
|
|
59
|
+
<button type="button" class="link remove">Remove</button>
|
|
60
|
+
</span>
|
|
61
|
+
</li>
|
|
62
|
+
</template>
|
|
63
|
+
<template id="glyph-local"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="2.5" y="3.5" width="11" height="7"/><path d="M1 12.5h14"/></svg></template>
|
|
64
|
+
<template id="glyph-remote"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="2.5" y="2.5" width="11" height="4"/><rect x="2.5" y="9.5" width="11" height="4"/><circle cx="5" cy="4.5" r=".9" fill="currentColor" stroke="none"/><circle cx="5" cy="11.5" r=".9" fill="currentColor" stroke="none"/></svg></template>
|
|
65
|
+
|
|
66
|
+
<script type="module" src="connect.js"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|