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 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));
@@ -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&nbsp;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>