symbiot 0.61.0 → 0.62.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/CHANGELOG.md +25 -0
- package/README.md +80 -34
- package/browser.html +2 -1
- package/core.mjs +3 -1
- package/guard.mjs +30 -1
- package/handover.mjs +1 -1
- package/headless.mjs +83 -8
- package/home.mjs +48 -12
- package/index.mjs +31 -17
- package/links.mjs +40 -5
- package/mdns.mjs +135 -0
- package/mind.mjs +1 -0
- package/package.json +5 -3
- package/phone.mjs +525 -78
- package/post.mjs +29 -12
- package/qr.mjs +160 -0
- package/server.mjs +26 -13
- package/tasks.mjs +12 -2
- package/ui.mjs +173 -35
package/phone.mjs
CHANGED
|
@@ -1,36 +1,120 @@
|
|
|
1
|
-
// symbiot —
|
|
2
|
-
//
|
|
3
|
-
// (the Android app, or Symbiot in Termux)
|
|
1
|
+
// symbiot — Your phone: what Symbiot on your computer has (your tasks, what needs
|
|
2
|
+
// you, agents' questions, what Watch found) on your phone, through the Symbiot
|
|
3
|
+
// there (the Android app, or Symbiot in Termux), and the few things you do from
|
|
4
|
+
// it (add and tick tasks, answer agents, approve) sent back.
|
|
4
5
|
//
|
|
5
|
-
// The computer: switched on in Settings, it listens on your network
|
|
6
|
-
// for just
|
|
7
|
-
// and
|
|
8
|
-
// and nothing
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// The computer: switched on in Settings (or Setup), it listens on your network
|
|
7
|
+
// (port 7392) for just this: pairing, with a 6-digit code (or its QR) shown for 10
|
|
8
|
+
// minutes, and sealed requests from a phone that paired. The app itself stays on
|
|
9
|
+
// 127.0.0.1, and nothing here reaches its API: a phone gets what's new, a copy of
|
|
10
|
+
// your work, and a short list of changes (PHONE_OPS), each checked again here.
|
|
11
|
+
// It also says it's here on the network (mdns.mjs), so a phone finds it after the
|
|
12
|
+
// router hands it a new address, and, with a phone paired, keeps a line open to the
|
|
13
|
+
// relay (relay/), so the phone reaches it away from home.
|
|
14
|
+
// The phone: paired once, it asks every 2 minutes while it runs (sooner after a
|
|
15
|
+
// failed try, and at once when its network changes), notifies what's new, keeps the
|
|
16
|
+
// copy (computer.json) to show with the computer off, and queues what you change
|
|
17
|
+
// meanwhile (phone-queue.json), sent in order once the computer is back.
|
|
11
18
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
19
|
+
// Sealed: at pairing the two agree a key (X25519, then HKDF), and every request and
|
|
20
|
+
// answer after is AES-256-GCM with it, stamped so it can't be replayed. The relay,
|
|
21
|
+
// and anyone on your Wi-Fi, see only that. The QR carries the computer key's
|
|
22
|
+
// fingerprint, so a scan can't pair with a stand-in.
|
|
23
|
+
//
|
|
24
|
+
// config.phoneLink = { on, port?, relay?: false to keep it to your Wi-Fi, pub,
|
|
25
|
+
// phones: [{ id, name, hash (of its token), pub, added }], gone: [{ id, pub, at }] }
|
|
26
|
+
// config.phoneSecret = { priv } (secrets.json) — the computer
|
|
27
|
+
// config.computer = { v: 2, url, urls, name, id, fp, pub, relay, relayUrl, since, offset, paired }
|
|
28
|
+
// config.computerSecret = { token, key } (secrets.json; on the Android app, sealed by
|
|
29
|
+
// its Keystore instead and handed in at start: SYMBIOT_COMPUTER_SECRET) — the phone
|
|
15
30
|
import { createServer } from "node:http";
|
|
16
31
|
import { hostname, networkInterfaces } from "node:os";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
|
|
34
|
+
import { randomBytes, randomInt, randomUUID, timingSafeEqual, createHash, generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
|
|
35
|
+
import { CONFIG_DIR, loadConfig, saveConfig, hasCmd, loadTasks } from "./core.mjs";
|
|
19
36
|
import { newsAfter, newsNotice } from "./watch.mjs";
|
|
20
37
|
import { desktopNotify } from "./desktop.mjs";
|
|
38
|
+
import { announce, browse } from "./mdns.mjs";
|
|
39
|
+
import { qrSvg } from "./qr.mjs";
|
|
21
40
|
|
|
22
41
|
const PORT = 7392;
|
|
23
42
|
const CODE_MS = 10 * 60 * 1000, CODE_TRIES = 5;
|
|
24
|
-
const POLL_MS = 2 * 60 * 1000;
|
|
25
|
-
const
|
|
43
|
+
const POLL_MS = 2 * 60 * 1000, BACKOFF = [15000, 30000, 60000]; // after a failed ask: sooner, then back to every 2 minutes
|
|
44
|
+
const SKEW_MS = 10 * 60 * 1000; // a request older (or newer) than this is refused: it could be a replay
|
|
45
|
+
const ANDROID_APP = process.env.SYMBIOT_ANDROID_APP === "1";
|
|
46
|
+
const PHONE = process.platform === "android" || ANDROID_APP || process.env.SYMBIOT_AS_PHONE === "1"; // the Android app, or Termux (or a test of either)
|
|
47
|
+
const PAIR_SITE = "https://symbiot.co.za/pair";
|
|
48
|
+
const relayBase = () => String(process.env.SYMBIOT_RELAY || "https://relay.symbiot.co.za").replace(/\/+$/, "");
|
|
49
|
+
const relayAllowed = () => process.env.SYMBIOT_NO_RELAY !== "1";
|
|
26
50
|
const same = (a, b) => { const x = Buffer.from(String(a || "")), y = Buffer.from(String(b || "")); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); };
|
|
51
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
52
|
+
const PATHS = { queue: join(CONFIG_DIR, "phone-queue.json"), copy: join(CONFIG_DIR, "computer.json"), applied: join(CONFIG_DIR, "phone-applied.json"),
|
|
53
|
+
seal: join(CONFIG_DIR, "android-seal.json"), nsdWant: join(CONFIG_DIR, "android-nsd.want"), nsd: join(CONFIG_DIR, "android-nsd.json") };
|
|
54
|
+
const readJson = (f, d) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return d; } };
|
|
55
|
+
const writeJson = (f, v) => { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(f, JSON.stringify(v), { mode: 0o600 }); return true; } catch { return false; } };
|
|
27
56
|
|
|
28
|
-
// ----
|
|
57
|
+
// ---- sealing --------------------------------------------------------------------------
|
|
58
|
+
const X25519_SPKI = Buffer.from("302a300506032b656e032100", "hex"); // DER head of an X25519 public key; the 32 raw bytes follow
|
|
59
|
+
function newKeys() {
|
|
60
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
61
|
+
return { pub: publicKey.export({ type: "spki", format: "der" }).subarray(12).toString("base64url"), priv: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64url") };
|
|
62
|
+
}
|
|
63
|
+
// The key a phone and its computer share: X25519 between one's private key and the
|
|
64
|
+
// other's public one, through HKDF, salted with both public keys (phone's first).
|
|
65
|
+
function linkKey(myPriv, theirPub, phonePub, computerPub) {
|
|
66
|
+
const s = diffieHellman({ privateKey: createPrivateKey({ key: Buffer.from(myPriv, "base64url"), format: "der", type: "pkcs8" }),
|
|
67
|
+
publicKey: createPublicKey({ key: Buffer.concat([X25519_SPKI, Buffer.from(theirPub, "base64url")]), format: "der", type: "spki" }) });
|
|
68
|
+
return Buffer.from(hkdfSync("sha256", s, Buffer.concat([Buffer.from(phonePub, "base64url"), Buffer.from(computerPub, "base64url")]), "symbiot phone link v1", 32));
|
|
69
|
+
}
|
|
70
|
+
// What a phone checks the computer by: in the QR, in the mDNS TXT record, pinned at pairing.
|
|
71
|
+
const fingerprint = (pub) => createHash("sha256").update(Buffer.from(String(pub || ""), "base64url")).digest("base64url").slice(0, 22);
|
|
72
|
+
// aad binds a sealed message to its place: a request to its phone, an answer to its request.
|
|
73
|
+
function seal(key, obj, aad) {
|
|
74
|
+
const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv); c.setAAD(Buffer.from(aad));
|
|
75
|
+
return { n: iv.toString("base64url"), c: Buffer.concat([c.update(JSON.stringify(obj)), c.final(), c.getAuthTag()]).toString("base64url") };
|
|
76
|
+
}
|
|
77
|
+
function unseal(key, env, aad) {
|
|
78
|
+
try {
|
|
79
|
+
const iv = Buffer.from(String(env.n), "base64url"), b = Buffer.from(String(env.c), "base64url");
|
|
80
|
+
if (iv.length !== 12 || b.length < 17) return null;
|
|
81
|
+
const d = createDecipheriv("aes-256-gcm", key, iv); d.setAAD(Buffer.from(aad)); d.setAuthTag(b.subarray(-16));
|
|
82
|
+
return JSON.parse(Buffer.concat([d.update(b.subarray(0, -16)), d.final()]).toString("utf8"));
|
|
83
|
+
} catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- the computer --------------------------------------------------------------------------
|
|
29
87
|
function linkCfg(cfg = loadConfig()) {
|
|
30
88
|
const l = cfg.phoneLink || {};
|
|
31
|
-
return { on: !!l.on, port: Number(l.port) || PORT,
|
|
89
|
+
return { on: !!l.on, port: Number(l.port) || PORT, relay: l.relay !== false, pub: String(l.pub || ""),
|
|
90
|
+
phones: (Array.isArray(l.phones) ? l.phones : []).filter((p) => p && p.id && (p.hash || p.token)), gone: (Array.isArray(l.gone) ? l.gone : []).filter((g) => g && g.id && g.pub).slice(-10) };
|
|
91
|
+
}
|
|
92
|
+
function saveLink(l) { const cfg = loadConfig(); cfg.phoneLink = { ...(cfg.phoneLink || {}), ...l }; return saveConfig(cfg); }
|
|
93
|
+
// Only a hash of each phone's token is kept: a copied config can't pose as a phone.
|
|
94
|
+
// One kept whole (paired before 0.59) is hashed the first time it's read.
|
|
95
|
+
function hashTokens() {
|
|
96
|
+
const cfg = loadConfig(), l = cfg.phoneLink; if (!l || !Array.isArray(l.phones) || !l.phones.some((p) => p && p.token)) return;
|
|
97
|
+
l.phones = l.phones.map((p) => (p && p.token ? (({ token, ...rest }) => ({ ...rest, hash: sha(token), ...(p.pub ? {} : { legacy: true }) }))(p) : p));
|
|
98
|
+
saveConfig(cfg);
|
|
99
|
+
}
|
|
100
|
+
// This computer's key pair, made the first time the link is switched on.
|
|
101
|
+
function linkKeys() {
|
|
102
|
+
const cfg = loadConfig(), l = cfg.phoneLink || {}, s = cfg.phoneSecret || {};
|
|
103
|
+
if (l.pub && s.priv) return { pub: l.pub, priv: s.priv };
|
|
104
|
+
const k = newKeys(); cfg.phoneLink = { ...l, pub: k.pub }; cfg.phoneSecret = { priv: k.priv }; saveConfig(cfg);
|
|
105
|
+
return k;
|
|
106
|
+
}
|
|
107
|
+
const keys = new Map(); // a phone's public key -> the key we share
|
|
108
|
+
function keyFor(phonePub) {
|
|
109
|
+
if (!keys.has(phonePub)) { const k = linkKeys(); keys.set(phonePub, linkKey(k.priv, phonePub, phonePub, k.pub)); }
|
|
110
|
+
return keys.get(phonePub);
|
|
111
|
+
}
|
|
112
|
+
// The relay knows this computer by a hash of a secret drawn from its key; only this
|
|
113
|
+
// computer can answer for it (it shows the secret, the relay hashes it). Phones get the id.
|
|
114
|
+
function relayOf(priv) {
|
|
115
|
+
const secret = Buffer.from(hkdfSync("sha256", Buffer.from(priv, "base64url"), Buffer.alloc(0), "symbiot relay v1", 32)).toString("base64url");
|
|
116
|
+
return { secret, id: createHash("sha256").update(secret).digest("base64url").slice(0, 32) };
|
|
32
117
|
}
|
|
33
|
-
function saveLink(l) { const cfg = loadConfig(); cfg.phoneLink = l; return saveConfig(cfg); }
|
|
34
118
|
// This computer's addresses on your network, the likeliest first (not Docker's,
|
|
35
119
|
// a VM's or a VPN's bridges).
|
|
36
120
|
function lanAddresses(ifs = networkInterfaces()) {
|
|
@@ -44,70 +128,268 @@ function lanAddresses(ifs = networkInterfaces()) {
|
|
|
44
128
|
}
|
|
45
129
|
|
|
46
130
|
let listener = null, listenErr = "", pairing = null; // pairing: { code, until, tries }
|
|
47
|
-
const lastSeen = new Map(); // phone id ->
|
|
131
|
+
const lastSeen = new Map(); // phone id -> { at, via }
|
|
132
|
+
// The link a QR carries: the site's pairing page, which hands it to the app (the
|
|
133
|
+
// part after # never leaves the phone). Addresses, port, code and key fingerprint.
|
|
134
|
+
function pairLink(code, l = linkCfg()) { return `${PAIR_SITE}#a=${lanAddresses().slice(0, 3).join(",")}&p=${l.port}&c=${code}&k=${fingerprint(linkKeys().pub)}`; }
|
|
48
135
|
function linkState() {
|
|
49
136
|
const l = linkCfg();
|
|
50
137
|
if (pairing && Date.now() > pairing.until) pairing = null;
|
|
138
|
+
const link = pairing && listener ? pairLink(pairing.code, l) : "";
|
|
51
139
|
return { role: "computer", on: l.on, port: l.port, listening: !!listener, ...(listenErr ? { error: listenErr } : {}), addresses: lanAddresses(),
|
|
52
|
-
|
|
53
|
-
...(
|
|
140
|
+
...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay, ...relayState() }, found: !!mdns,
|
|
141
|
+
phones: l.phones.map((p) => ({ id: p.id, name: p.name, added: p.added, ...(p.legacy || !p.pub ? { old: true } : {}), ...(lastSeen.has(p.id) ? { seen: lastSeen.get(p.id).at, via: lastSeen.get(p.id).via } : p.seen ? { seen: p.seen } : {}) })),
|
|
142
|
+
...(pairing ? { code: pairing.code, until: pairing.until } : {}), ...(link ? { link, qr: qrSvg(link) } : {}) };
|
|
54
143
|
}
|
|
55
144
|
// A new pairing code, good for 10 minutes and 5 tries.
|
|
56
145
|
function newCode() { pairing = { code: String(randomInt(0, 1e6)).padStart(6, "0"), until: Date.now() + CODE_MS, tries: 0 }; return linkState(); }
|
|
57
|
-
|
|
146
|
+
// Unpaired: it gets nothing more, and what it had queued (an approve too) is refused.
|
|
147
|
+
// Its key is kept a while, so it's told so, sealed, rather than left guessing.
|
|
148
|
+
function unpairPhone(id) {
|
|
149
|
+
const l = linkCfg(), p = l.phones.find((x) => x.id === id);
|
|
150
|
+
l.phones = l.phones.filter((x) => x.id !== id); if (p && p.pub) l.gone = [...l.gone, { id: p.id, pub: p.pub, at: Date.now() }].slice(-10);
|
|
151
|
+
lastSeen.delete(id); saveLink({ phones: l.phones, gone: l.gone });
|
|
152
|
+
if (!l.phones.some((x) => x.pub)) stopRelay();
|
|
153
|
+
return linkState();
|
|
154
|
+
}
|
|
155
|
+
function setRelay(on) { saveLink({ relay: !!on }); if (on) startRelay(); else stopRelay(); return linkState(); }
|
|
58
156
|
|
|
59
157
|
function reply(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(obj)); }
|
|
60
|
-
function body(req) {
|
|
158
|
+
function body(req, max = 256 * 1024) {
|
|
61
159
|
return new Promise((resolve) => {
|
|
62
|
-
let d = ""; req.on("data", (ch) => { d += ch; if (d.length >
|
|
160
|
+
let d = ""; req.on("data", (ch) => { d += ch; if (d.length > max) req.destroy(); });
|
|
63
161
|
req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
|
|
64
162
|
});
|
|
65
163
|
}
|
|
164
|
+
const NOT_PAIRED = "This phone isn't paired with this computer. Pair it again: in Symbiot on your computer, Settings → Your phone.";
|
|
165
|
+
const UNPAIRED = "This phone was unpaired on your computer. Pair it again to get its news and send it changes.";
|
|
166
|
+
const OLD_PHONE = "Symbiot on your computer now seals what it sends your phone. Update Symbiot on this phone, then pair it again.";
|
|
66
167
|
async function handle(req, res) {
|
|
67
168
|
const u = new URL(req.url, "http://phone");
|
|
169
|
+
if (u.pathname === "/phone/hello" && req.method === "GET") { const l = linkCfg(); return reply(res, 200, { v: 1, pub: l.pub || linkKeys().pub, fp: fingerprint(l.pub || linkKeys().pub) }); }
|
|
68
170
|
if (u.pathname === "/phone/pair" && req.method === "POST") {
|
|
69
|
-
const b = await body(req);
|
|
70
|
-
if (!
|
|
71
|
-
if (!
|
|
171
|
+
const b = await body(req, 4096);
|
|
172
|
+
if (!b.pub || !b.c) return reply(res, 400, { error: OLD_PHONE });
|
|
173
|
+
if (!pairing || Date.now() > pairing.until) { pairing = null; return reply(res, 403, { error: "No pairing code is open. In Symbiot on your computer, open Settings → Your phone and click Pair a phone." }); }
|
|
174
|
+
const k = linkKeys(), key = linkKey(k.priv, String(b.pub), String(b.pub), k.pub), m = unseal(key, b, "pair");
|
|
175
|
+
if (!m || !same(String(m.code || "").replace(/\D/g, ""), pairing.code)) {
|
|
72
176
|
if (++pairing.tries >= CODE_TRIES) pairing = null;
|
|
73
177
|
return reply(res, 403, { error: pairing ? "That code isn't right. Check it on your computer." : "That code was wrong too many times. Click New code on your computer." });
|
|
74
178
|
}
|
|
75
179
|
pairing = null;
|
|
76
|
-
const l = linkCfg(), p = { id: randomBytes(4).toString("hex"), name: String(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (u.pathname === "/phone/news" && req.method === "GET") {
|
|
81
|
-
const p = linkCfg().phones.find((x) => same(x.token, req.headers["x-symbiot-phone"]));
|
|
82
|
-
if (!p) return reply(res, 403, { error: "This phone isn't paired any more. Pair it again." });
|
|
83
|
-
lastSeen.set(p.id, Date.now());
|
|
84
|
-
const { news, briefs } = newsAfter(u.searchParams.get("since"));
|
|
85
|
-
return reply(res, 200, { name: hostname(), now: Date.now(), news: news.slice(0, 100), briefs: briefs.slice(0, 20) });
|
|
180
|
+
const l = linkCfg(), token = randomBytes(24).toString("hex"), p = { id: randomBytes(4).toString("hex"), name: String(m.name || "Phone").replace(/\s+/g, " ").trim().slice(0, 40) || "Phone", hash: sha(token), pub: String(b.pub), added: Date.now() };
|
|
181
|
+
saveLink({ phones: [...l.phones, p].slice(-5) });
|
|
182
|
+
keys.set(p.pub, key); startRelay();
|
|
183
|
+
return reply(res, 200, seal(key, { token, id: p.id, name: hostname(), now: Date.now(), port: l.port, addresses: lanAddresses().slice(0, 3), relay: relayOf(k.priv).id, relayUrl: relayBase() }, "pair:" + b.n));
|
|
86
184
|
}
|
|
185
|
+
// a phone paired before the link was sealed
|
|
186
|
+
if (u.pathname === "/phone/news" && req.method === "GET") return reply(res, 403, { error: OLD_PHONE });
|
|
187
|
+
const op = (u.pathname.match(/^\/phone\/(news|state|apply)$/) || [])[1];
|
|
188
|
+
if (op && req.method === "POST") { const r = await answer(await body(req), op, "wifi"); return reply(res, r.status, r.body); }
|
|
87
189
|
reply(res, 404, { error: "not found" });
|
|
88
190
|
}
|
|
191
|
+
|
|
192
|
+
// A sealed request, from your Wi-Fi or through the relay: who sent it (its token,
|
|
193
|
+
// checked against the hash kept), that it's fresh, and what it asks. Gives { status,
|
|
194
|
+
// body }: sealed, or plain only when it can't be sealed (no such phone).
|
|
195
|
+
const seenNonces = new Map();
|
|
196
|
+
const sealed = (key, n, s, b) => ({ status: 200, body: seal(key, { s, b }, "res:" + n) });
|
|
197
|
+
async function answer(env, op, via) {
|
|
198
|
+
env = env && typeof env === "object" ? env : {};
|
|
199
|
+
const l = linkCfg(), p = l.phones.find((x) => x.id === env.id && x.pub && !x.legacy);
|
|
200
|
+
if (!p) {
|
|
201
|
+
const g = l.gone.find((x) => x.id === env.id);
|
|
202
|
+
if (g) { const k = keyFor(g.pub); if (unseal(k, env, "req:" + g.id)) return sealed(k, env.n, 403, { error: UNPAIRED, unpaired: true }); }
|
|
203
|
+
return { status: 403, body: { error: NOT_PAIRED } };
|
|
204
|
+
}
|
|
205
|
+
const key = keyFor(p.pub), m = unseal(key, env, "req:" + p.id);
|
|
206
|
+
if (!m) return { status: 403, body: { error: NOT_PAIRED } };
|
|
207
|
+
const ok = p.hash ? same(sha(String(m.t || "")), p.hash) : same(m.t, p.token);
|
|
208
|
+
if (!ok) return sealed(key, env.n, 403, { error: UNPAIRED, unpaired: true });
|
|
209
|
+
if (p.token) hashTokens();
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
for (const [n, t] of seenNonces) if (now - t > 2 * SKEW_MS) seenNonces.delete(n);
|
|
212
|
+
if (Math.abs(now - Number(m.ts)) > SKEW_MS || seenNonces.has(env.n)) return sealed(key, env.n, 409, { error: "That message was old, or came twice, so it was ignored. Check the time on your phone." });
|
|
213
|
+
seenNonces.set(env.n, now);
|
|
214
|
+
if ((op && m.op !== op) || !OPS[m.op]) return sealed(key, env.n, 403, { error: "That can't be asked from a phone." });
|
|
215
|
+
lastSeen.set(p.id, { at: now, via: via || "wifi" });
|
|
216
|
+
try { const r = await OPS[m.op](m.args && typeof m.args === "object" ? m.args : {}, p); return sealed(key, env.n, r.status || 200, r.body !== undefined ? r.body : r); }
|
|
217
|
+
catch (e) { return sealed(key, env.n, 500, { error: "It failed on your computer: " + ((e && e.message) || e) }); }
|
|
218
|
+
}
|
|
219
|
+
const OPS = {
|
|
220
|
+
news: ({ since }) => { const { news, briefs } = newsAfter(since); return { name: hostname(), now: Date.now(), news: news.slice(0, 100), briefs: briefs.slice(0, 20) }; },
|
|
221
|
+
state: async ({ etag }) => { const s = await snapshot(); return etag && etag === s.v ? { status: 304, body: { v: s.v } } : s; },
|
|
222
|
+
apply: async ({ ops }, phone) => applyOps(ops, phone),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ---- the copy a phone keeps ---------------------------------------------------------------
|
|
226
|
+
// Small and in plain words: your open tasks, what needs you, agents' questions, the
|
|
227
|
+
// work waiting for your Approve (with a sum of exactly what's waiting, which an
|
|
228
|
+
// approve from the phone has to match), what Watch found, and what was approved
|
|
229
|
+
// from a phone lately. v changes only when something in it does.
|
|
230
|
+
let snapCache = null;
|
|
231
|
+
const plainText = (s, n = 280) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? t.slice(0, n - 1) + "…" : t; };
|
|
232
|
+
const approveSum = (r) => sha(JSON.stringify([(r.tasks || []).map((t) => t.id).sort(), (r.files || []).map((f) => (typeof f === "string" ? f : f.path || f.file || JSON.stringify(f))).sort(), r.stat || ""])).slice(0, 16);
|
|
233
|
+
const askId = (path) => "ask:" + sha(String(path)).slice(0, 12);
|
|
234
|
+
async function snapshot({ now = Date.now(), fresh = false } = {}) {
|
|
235
|
+
if (!fresh && snapCache && now - snapCache.at < 5000) return snapCache.snap;
|
|
236
|
+
const tryOr = async (f, d) => { try { return await f(); } catch { return d; } };
|
|
237
|
+
const home = await tryOr(() => import("./home.mjs"), null), tasksMod = await tryOr(() => import("./tasks.mjs"), null), agents = await tryOr(() => import("./agents.mjs"), null), watch = await tryOr(() => import("./watch.mjs"), null);
|
|
238
|
+
const h = home ? await tryOr(() => home.homeState({ now }), { you: [] }) : { you: [] };
|
|
239
|
+
const tasks = loadTasks().filter((t) => !t.archived).slice(0, 200).map((t) => ({ id: t.id, text: plainText(t.text), repo: t.repo || "", done: !!t.done, ...(t.review ? { review: true } : {}) }));
|
|
240
|
+
const you = h.you || [];
|
|
241
|
+
const needs = you.filter((y) => y.kind !== "ask" && y.kind !== "approve").map((y) => ({ id: y.id, title: plainText(y.title, 90), sub: plainText(y.sub, 160) }));
|
|
242
|
+
const asks = [];
|
|
243
|
+
for (const y of (await tryOr(() => agents.agentsList(), [])).filter((a, i, all) => all.findIndex((b) => b.path === a.path) === i)) {
|
|
244
|
+
const qs = await tryOr(() => agents.agentQuestions(y.path, "").questions, []);
|
|
245
|
+
if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)) })) });
|
|
246
|
+
}
|
|
247
|
+
const approves = (await tryOr(() => tasksMod.pendingReview(), [])).filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length))
|
|
248
|
+
.map((r) => ({ repo: r.repo, tasks: (r.tasks || []).map((t) => ({ id: t.id, text: plainText(t.text, 200) })), files: (r.files || []).length, stat: plainText(r.stat, 120), ...(r.untasked ? { untasked: true } : {}), sum: approveSum(r) }));
|
|
249
|
+
const board = watch ? await tryOr(() => watch.watchBoard(24, now), { cards: [] }) : { cards: [] };
|
|
250
|
+
const seen = (board.cards || []).filter((c) => c.count > 0).slice(0, 8).map((c) => ({ name: plainText(c.name, 60), label: plainText(c.label, 60), count: c.count, items: (c.items || []).filter((i) => i.need !== false).slice(0, 3).map((i) => ({ text: plainText(i.text, 140), ts: i.ts })) }));
|
|
251
|
+
const recent = (readJson(PATHS.applied, {}).approvals || []).filter((a) => now - a.at < 24 * 3600000).slice(-10);
|
|
252
|
+
const body = { tasks, needs, asks, approves, watch: seen, recent, relay: linkCfg().relay };
|
|
253
|
+
const snap = { v: sha(JSON.stringify(body)).slice(0, 16), name: hostname(), at: now, ...body };
|
|
254
|
+
snapCache = { at: now, snap };
|
|
255
|
+
return snap;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- changes from a phone --------------------------------------------------------------------
|
|
259
|
+
// Only these, each absolute ("mark it done", never "flip it"), so one sent twice or
|
|
260
|
+
// late lands the same. Each has an id, and an id applied once is never applied
|
|
261
|
+
// again (phone-applied.json). Anything else is refused and reaches nothing.
|
|
262
|
+
const PHONE_OPS = ["task.add", "task.setDone", "agent.answer", "pending.approve"];
|
|
263
|
+
let approveHook = null; // server.mjs: the same Approve as the button (release notes and all)
|
|
264
|
+
function setPhoneApprove(fn) { approveHook = typeof fn === "function" ? fn : null; }
|
|
265
|
+
async function defaultApprove(repo, untasked) { const t = await import("./tasks.mjs"); return untasked ? t.approveChanges(repo, { tick: [] }) : t.approveRepo(repo, {}); }
|
|
266
|
+
async function applyOne(o, phone) {
|
|
267
|
+
const a = o.args && typeof o.args === "object" ? o.args : {}, at = Number(o.at) || Date.now();
|
|
268
|
+
if (o.op === "task.add") {
|
|
269
|
+
const text = String(a.text || "").trim().slice(0, 4000); if (!text) return { error: "That task was empty." };
|
|
270
|
+
const repo = String(a.repo || ""), { laneMap } = await import("./scan.mjs");
|
|
271
|
+
if (repo && !(repo in laneMap())) return { error: `There's no ${repo} on your computer any more, so the task wasn't added.` };
|
|
272
|
+
const { addTask } = await import("./tasks.mjs"), t = addTask(text, repo);
|
|
273
|
+
return t.error ? { error: "It couldn't be added." } : { ok: true, task: t.id, ...(t.duplicate ? { duplicate: true } : {}) };
|
|
274
|
+
}
|
|
275
|
+
if (o.op === "task.setDone") {
|
|
276
|
+
const { setTaskDone } = await import("./tasks.mjs"), r = setTaskDone(String(a.id || ""), !!a.done, { at });
|
|
277
|
+
if (r.conflict) return { conflict: true, error: `"${plainText(r.text, 60)}" was changed on your computer after you changed it here, so your computer's stays.` };
|
|
278
|
+
return r.error ? { error: r.error === "not found" ? "That task isn't on your computer any more." : r.error } : { ok: true };
|
|
279
|
+
}
|
|
280
|
+
if (o.op === "agent.answer") {
|
|
281
|
+
const path = String(a.path || ""), snap = await snapshot({ fresh: true });
|
|
282
|
+
if (!snap.asks.some((x) => x.path === path)) return { error: "That agent isn't asking any more (answered on your computer, or its run moved on)." };
|
|
283
|
+
const { answerQuestions } = await import("./agents.mjs"), r = answerQuestions(path, Array.isArray(a.answers) ? a.answers.slice(0, 5) : [], { rerun: true });
|
|
284
|
+
return r && r.ok ? { ok: true, ...(r.note ? { note: r.note } : {}) } : { error: (r && r.error) || "It couldn't be answered." };
|
|
285
|
+
}
|
|
286
|
+
if (o.op === "pending.approve") {
|
|
287
|
+
const repo = String(a.repo || ""), { pendingReview } = await import("./tasks.mjs");
|
|
288
|
+
const now = pendingReview().find((r) => r.repo === repo && r.path && ((r.tasks || []).length || (r.files || []).length));
|
|
289
|
+
if (!now) return { error: `Nothing in ${repo} is waiting for your Approve any more.` };
|
|
290
|
+
if (now.running) return { error: `The agent is still working in ${repo}. Approve once it finishes.` };
|
|
291
|
+
if (approveSum(now) !== String(a.sum || "")) return { refused: true, error: `The work in ${repo} changed since your phone showed it, so it wasn't approved. Look at what's there now, then approve that.` };
|
|
292
|
+
const ap = readJson(PATHS.applied, { ids: {}, approvals: [] }), entry = { id: o.id, repo, phone: phone.name, at: Date.now(), status: "approving" };
|
|
293
|
+
ap.approvals = [...(ap.approvals || []), entry].slice(-30); writeJson(PATHS.applied, { ids: ap.ids || {}, approvals: ap.approvals });
|
|
294
|
+
// Approve takes a while (a branch, a push, a PR): it carries on here, and the copy says how it went
|
|
295
|
+
Promise.resolve().then(() => (approveHook || defaultApprove)(repo, !!now.untasked)).then((r) => r || {}, (e) => ({ error: String((e && e.message) || e) })).then((r) => {
|
|
296
|
+
const cur = readJson(PATHS.applied, { ids: {}, approvals: [] }), x = (cur.approvals || []).find((y) => y.id === o.id);
|
|
297
|
+
if (x) { x.status = r.error ? "failed" : "approved"; if (r.error) x.error = plainText(r.error, 200); if (r.pr) x.pr = r.pr; x.done = Date.now(); writeJson(PATHS.applied, cur); }
|
|
298
|
+
snapCache = null;
|
|
299
|
+
});
|
|
300
|
+
return { ok: true, started: true };
|
|
301
|
+
}
|
|
302
|
+
return { refused: true, status: 403, error: `"${String(o.op || "").slice(0, 40)}" can't be done from a phone.` };
|
|
303
|
+
}
|
|
304
|
+
async function applyOps(ops, phone) {
|
|
305
|
+
ops = (Array.isArray(ops) ? ops : []).slice(0, 50);
|
|
306
|
+
const results = [];
|
|
307
|
+
for (const o of ops) {
|
|
308
|
+
const id = String((o && o.id) || "");
|
|
309
|
+
if (!/^[\w-]{8,64}$/.test(id)) { results.push({ id, refused: true, status: 403, error: "That isn't a change Symbiot knows." }); continue; }
|
|
310
|
+
const ap = readJson(PATHS.applied, { ids: {}, approvals: [] }); ap.ids = ap.ids || {};
|
|
311
|
+
if (ap.ids[id]) { results.push({ id, ok: true, again: true }); continue; }
|
|
312
|
+
const r = PHONE_OPS.includes(o.op) ? await applyOne(o, phone) : { refused: true, status: 403, error: `"${String(o.op || "").slice(0, 40)}" can't be done from a phone.` };
|
|
313
|
+
const now = readJson(PATHS.applied, { ids: {}, approvals: [] }), ids = { ...(now.ids || {}), [id]: Date.now() };
|
|
314
|
+
const keep = Object.entries(ids).sort((x, y) => y[1] - x[1]).slice(0, 2000); // a month of changes, more than any queue holds
|
|
315
|
+
writeJson(PATHS.applied, { ids: Object.fromEntries(keep), approvals: now.approvals || [] });
|
|
316
|
+
results.push({ id, ...r });
|
|
317
|
+
}
|
|
318
|
+
snapCache = null;
|
|
319
|
+
return { results, state: await snapshot({ fresh: true }) };
|
|
320
|
+
}
|
|
321
|
+
// Approved from a phone, lately: Home and the Workdesk say so.
|
|
322
|
+
function phoneApprovals(now = Date.now()) { return (readJson(PATHS.applied, {}).approvals || []).filter((a) => now - a.at < 24 * 3600000); }
|
|
323
|
+
|
|
324
|
+
// ---- saying it's here (mdns.mjs) ----------------------------------------------------------------
|
|
325
|
+
let mdns = null;
|
|
326
|
+
function startMdns() {
|
|
327
|
+
if (mdns || process.env.SYMBIOT_NO_MDNS === "1") return;
|
|
328
|
+
const l = linkCfg(), k = linkKeys();
|
|
329
|
+
mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: () => lanAddresses() }, { onError: () => {} });
|
|
330
|
+
}
|
|
331
|
+
function stopMdns() { if (mdns) mdns.stop(); mdns = null; }
|
|
332
|
+
|
|
333
|
+
// ---- away from home: the relay (relay/) --------------------------------------------------------
|
|
334
|
+
// While the link is on and a phone is paired, the computer keeps a line open to the
|
|
335
|
+
// relay (it connects out: no port opened on your router), and answers what a phone
|
|
336
|
+
// sends through it as it would on your Wi-Fi. The relay passes sealed messages only.
|
|
337
|
+
let relaySock = null, relayErr = "", relayUp = false, relayTries = 0, relayTimer = null, relayPing = null;
|
|
338
|
+
function relayState() { return { connected: relayUp, ...(relayErr ? { error: relayErr } : {}) }; }
|
|
339
|
+
function relayWanted() { const l = linkCfg(); return l.on && l.relay && relayAllowed() && l.phones.some((p) => p.pub && !p.legacy); }
|
|
340
|
+
function startRelay() {
|
|
341
|
+
if (relaySock || relayTimer || !relayWanted()) return;
|
|
342
|
+
if (typeof globalThis.WebSocket !== "function") { relayErr = "Away from home needs Node 22 or newer on this computer."; return; }
|
|
343
|
+
const { id, secret } = relayOf(linkKeys().priv);
|
|
344
|
+
let ws; try { ws = new WebSocket(relayBase().replace(/^http/, "ws") + `/v1/${id}/computer`); } catch (e) { relayErr = String((e && e.message) || e); return retryRelay(); }
|
|
345
|
+
relaySock = ws;
|
|
346
|
+
ws.addEventListener("open", () => { try { ws.send(JSON.stringify({ hello: secret })); } catch {} });
|
|
347
|
+
ws.addEventListener("message", async (ev) => {
|
|
348
|
+
let m; try { m = JSON.parse(String(ev.data)); } catch { return; }
|
|
349
|
+
if (m.ready) { relayUp = true; relayErr = ""; relayTries = 0; return; }
|
|
350
|
+
if (m.error) { relayErr = String(m.error).slice(0, 200); return; }
|
|
351
|
+
if (!m.rid) return;
|
|
352
|
+
const r = await answer(m.body, null, "relay");
|
|
353
|
+
try { ws.send(JSON.stringify({ rid: m.rid, s: r.status, b: r.body })); } catch {}
|
|
354
|
+
});
|
|
355
|
+
ws.addEventListener("close", () => { if (relaySock === ws) { relaySock = null; relayUp = false; retryRelay(); } });
|
|
356
|
+
ws.addEventListener("error", () => { relayErr = relayErr || `Couldn't reach ${relayBase()}.`; });
|
|
357
|
+
clearInterval(relayPing); relayPing = setInterval(() => { try { if (relaySock && relaySock.readyState === 1) relaySock.send('{"ping":1}'); } catch {} }, 30000); relayPing.unref();
|
|
358
|
+
}
|
|
359
|
+
function retryRelay() {
|
|
360
|
+
if (relayTimer || !relayWanted()) return;
|
|
361
|
+
const wait = Math.min(5 * 60000, 2000 * 2 ** Math.min(relayTries++, 8));
|
|
362
|
+
relayTimer = setTimeout(() => { relayTimer = null; startRelay(); }, wait); relayTimer.unref();
|
|
363
|
+
}
|
|
364
|
+
function stopRelay() {
|
|
365
|
+
clearTimeout(relayTimer); relayTimer = null; clearInterval(relayPing); relayPing = null;
|
|
366
|
+
const ws = relaySock; relaySock = null; relayUp = false; relayTries = 0;
|
|
367
|
+
if (ws) try { ws.close(); } catch {}
|
|
368
|
+
}
|
|
369
|
+
|
|
89
370
|
// Listen on the network while it's switched on. Gives the state.
|
|
90
371
|
function startPhoneLink() {
|
|
91
372
|
const l = linkCfg();
|
|
92
373
|
if (!l.on || listener) return Promise.resolve(linkState());
|
|
374
|
+
hashTokens(); linkKeys();
|
|
93
375
|
return new Promise((resolve) => {
|
|
94
376
|
const srv = createServer((req, res) => { handle(req, res).catch(() => { try { reply(res, 500, { error: "failed" }); } catch {} }); });
|
|
95
377
|
srv.on("error", (e) => {
|
|
96
378
|
listenErr = e && e.code === "EADDRINUSE" ? `Port ${l.port} is in use by something else on this computer.` : `Couldn't listen on port ${l.port}: ${(e && e.message) || e}`;
|
|
97
379
|
listener = null; resolve(linkState());
|
|
98
380
|
});
|
|
99
|
-
srv.listen(l.port, "0.0.0.0", () => { listener = srv; listenErr = ""; srv.unref(); resolve(linkState()); });
|
|
381
|
+
srv.listen(l.port, "0.0.0.0", () => { listener = srv; listenErr = ""; srv.unref(); startMdns(); startRelay(); resolve(linkState()); });
|
|
100
382
|
});
|
|
101
383
|
}
|
|
102
|
-
function stopPhoneLink() { if (listener) { listener.close(); if (listener.closeAllConnections) listener.closeAllConnections(); } listener = null; pairing = null; }
|
|
384
|
+
function stopPhoneLink() { if (listener) { listener.close(); if (listener.closeAllConnections) listener.closeAllConnections(); } listener = null; pairing = null; stopMdns(); stopRelay(); }
|
|
103
385
|
async function setPhoneLink(on) {
|
|
104
|
-
|
|
386
|
+
saveLink({ on: !!on });
|
|
105
387
|
if (!on) { stopPhoneLink(); listenErr = ""; return linkState(); }
|
|
106
388
|
await startPhoneLink();
|
|
107
389
|
return listener && !pairing ? newCode() : linkState();
|
|
108
390
|
}
|
|
109
391
|
|
|
110
|
-
// ---- the phone
|
|
392
|
+
// ---- the phone -------------------------------------------------------------------------------
|
|
111
393
|
// What the user typed as the computer's address: "192.168.8.50", ":7392" optional.
|
|
112
394
|
function computerUrl(input) {
|
|
113
395
|
let s = String(input || "").trim().replace(/\/+$/, "");
|
|
@@ -115,53 +397,218 @@ function computerUrl(input) {
|
|
|
115
397
|
if (!/^https?:\/\//i.test(s)) s = "http://" + s;
|
|
116
398
|
try { const u = new URL(s); if (!u.hostname) return ""; if (!u.port) u.port = String(PORT); return `${u.protocol}//${u.host}`; } catch { return ""; }
|
|
117
399
|
}
|
|
118
|
-
|
|
119
|
-
|
|
400
|
+
// A scanned pairing link: the site's (https://symbiot.co.za/pair#a=…) or the app's
|
|
401
|
+
// own (symbiot://pair?a=…). { addresses, port, code, fp } or null.
|
|
402
|
+
function parsePairLink(s) {
|
|
403
|
+
s = String(s || "").trim();
|
|
404
|
+
const m = s.match(/^(?:https:\/\/(?:www\.)?symbiot\.co\.za\/pair\/?[#?]|symbiot:\/\/pair\/?\?)(.*)$/i); if (!m) return null;
|
|
405
|
+
const q = new URLSearchParams(m[1].replace(/^[#?]/, "")), addresses = String(q.get("a") || "").split(",").map((a) => a.trim()).filter((a) => /^[\w.:-]+$/.test(a)).slice(0, 5);
|
|
406
|
+
const port = Number(q.get("p")) || PORT, code = String(q.get("c") || "").replace(/\D/g, ""), fp = String(q.get("k") || "");
|
|
407
|
+
return addresses.length && /^\d{6}$/.test(code) ? { addresses, port, code, fp } : null;
|
|
408
|
+
}
|
|
409
|
+
const canNotify = () => ANDROID_APP || process.platform !== "android" || hasCmd("termux-notification");
|
|
410
|
+
// The phone's half of the key, its token too: sealed by the Android app's Keystore
|
|
411
|
+
// there (android-seal.json is how it's handed over, for a few seconds; it comes back
|
|
412
|
+
// in SYMBIOT_COMPUTER_SECRET at the next start), else in secrets.json (0600).
|
|
413
|
+
let memSecret;
|
|
414
|
+
function readSecret() {
|
|
415
|
+
if (ANDROID_APP) {
|
|
416
|
+
if (memSecret === undefined) { memSecret = readJson(PATHS.seal, null); try { if (!memSecret || !memSecret.token) memSecret = JSON.parse(process.env.SYMBIOT_COMPUTER_SECRET || "null"); } catch { memSecret = null; } }
|
|
417
|
+
return memSecret && memSecret.token && memSecret.key ? memSecret : null;
|
|
418
|
+
}
|
|
419
|
+
const s = loadConfig().computerSecret; return s && s.token && s.key ? s : null;
|
|
420
|
+
}
|
|
421
|
+
function writeSecret(s) {
|
|
422
|
+
if (ANDROID_APP) { memSecret = s || null; process.env.SYMBIOT_COMPUTER_SECRET = s ? JSON.stringify(s) : ""; writeJson(PATHS.seal, s || {}); return; }
|
|
423
|
+
const cfg = loadConfig(); if (s) cfg.computerSecret = s; else delete cfg.computerSecret; saveConfig(cfg);
|
|
424
|
+
}
|
|
425
|
+
let pollLast = 0, pollErr = "", pollTimer = null, fails = 0, lastVia = "", polling = null;
|
|
426
|
+
const loadQueue = () => { const q = readJson(PATHS.queue, []); return Array.isArray(q) ? q : []; };
|
|
427
|
+
const saveQueue = (q) => (q.length ? writeJson(PATHS.queue, q) : (() => { try { unlinkSync(PATHS.queue); } catch {} return true; })());
|
|
428
|
+
const loadCopy = () => readJson(PATHS.copy, null);
|
|
120
429
|
function computerState() {
|
|
121
|
-
const c = loadConfig().computer;
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
430
|
+
const c = loadConfig().computer, s = c && readSecret(), copy = c ? loadCopy() : null, heard = Math.max(pollLast, (copy && copy.got) || 0);
|
|
431
|
+
const old = !!c && !s; // paired before the link was sealed (or its key is gone)
|
|
432
|
+
return { role: "phone", paired: !!(c && (c.token || s)), ...(c ? { name: c.name, url: c.url } : {}), ...(old ? { old: true } : {}), ...(pollLast ? { last: pollLast } : {}), ...(heard ? { heard } : {}),
|
|
433
|
+
...(lastVia ? { via: lastVia } : {}), ...(c && !old && fails >= 2 ? { away: true } : {}), ...(old ? { error: OLD_PAIRING } : pollErr ? { error: pollErr } : {}),
|
|
434
|
+
queued: c ? loadQueue().length : 0, notify: canNotify(), termux: PHONE && !ANDROID_APP };
|
|
435
|
+
}
|
|
436
|
+
const OLD_PAIRING = "Pair this phone with your computer again: the link between them is sealed now, and this pairing is from before.";
|
|
437
|
+
const OLD_COMPUTER = "Symbiot on your computer is older than this phone's. Update it there (Settings → Update), then pair again.";
|
|
438
|
+
const cantReach = (url) => `Couldn't reach ${url}. Is Symbiot running there, with Your phone switched on, and is this phone on the same Wi-Fi? A firewall on the computer may need to allow port ${new URL(url).port}.`;
|
|
439
|
+
// Pair with the address and code typed in, or a scanned link (opts.link), which
|
|
440
|
+
// gives both and the key to expect; each address in it is tried until one answers.
|
|
441
|
+
async function pairComputer(address, code, { fetchFn = fetch, name = hostname(), link = "" } = {}) {
|
|
442
|
+
const err = (e) => ({ ...computerState(), error: e });
|
|
443
|
+
const p = link ? parsePairLink(link) : null;
|
|
444
|
+
if (link && !p) return err("That isn't a pairing code from Symbiot. On your computer, open Settings → Your phone and scan the code it shows.");
|
|
445
|
+
const urls = p ? p.addresses.map((a) => computerUrl(`${a}:${p.port}`)).filter(Boolean) : [computerUrl(address)].filter(Boolean);
|
|
446
|
+
if (!urls.length) return err("Type your computer's address, as Symbiot shows it there (like 192.168.8.50:7392).");
|
|
447
|
+
code = p ? p.code : String(code || "").replace(/\s/g, "");
|
|
448
|
+
if (!/^\d{6}$/.test(code)) return err("Type the 6-digit code your computer shows.");
|
|
449
|
+
let last = "";
|
|
450
|
+
for (const url of urls) {
|
|
451
|
+
let h;
|
|
452
|
+
try { const r = await fetchFn(url + "/phone/hello", { signal: AbortSignal.timeout(5000) }); h = r.ok ? await r.json().catch(() => null) : null; if (!h || !h.pub) { last = OLD_COMPUTER; continue; } }
|
|
453
|
+
catch { last = cantReach(url); continue; }
|
|
454
|
+
if (p && p.fp && fingerprint(h.pub) !== p.fp) { last = `The computer at ${url} isn't the one whose code you scanned. Scan it again on your computer.`; continue; }
|
|
455
|
+
const mine = newKeys(), key = linkKey(mine.priv, h.pub, mine.pub, h.pub), env = { v: 1, pub: mine.pub, ...seal(key, { code, name: name === "localhost" ? "Phone" : name }, "pair") };
|
|
456
|
+
let r, j;
|
|
457
|
+
try { r = await fetchFn(url + "/phone/pair", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(8000) }); j = await r.json().catch(() => ({})); }
|
|
458
|
+
catch { last = cantReach(url); continue; }
|
|
459
|
+
if (!r.ok) return err(j.error || `Your computer answered ${r.status}.`);
|
|
460
|
+
const m = unseal(key, j, "pair:" + env.n);
|
|
461
|
+
if (!m || !m.token) return err("Your computer's answer couldn't be read. Pair again with a new code.");
|
|
462
|
+
const cfg = loadConfig(), was = cfg.computer, fp = fingerprint(h.pub);
|
|
463
|
+
const more = (Array.isArray(m.addresses) ? m.addresses : []).map((a) => computerUrl(`${a}:${Number(m.port) || PORT}`)).filter(Boolean);
|
|
464
|
+
cfg.computer = { v: 2, url, urls: [...new Set([url, ...more, ...urls])].slice(0, 6), name: String(m.name || "your computer").slice(0, 60), id: String(m.id), fp, pub: h.pub,
|
|
465
|
+
relay: String(m.relay || ""), relayUrl: String(m.relayUrl || ""), since: Number(m.now) || Date.now(), offset: (Number(m.now) || Date.now()) - Date.now(), paired: Date.now() };
|
|
466
|
+
saveConfig(cfg); writeSecret({ token: m.token, key: key.toString("base64url") });
|
|
467
|
+
if (!was || was.fp !== fp) { try { unlinkSync(PATHS.copy); } catch {} saveQueue([]); } // another computer: what was kept for the last one isn't its
|
|
468
|
+
pollErr = ""; fails = 0; lastVia = ""; startComputerPoll({ soon: true });
|
|
469
|
+
return computerState();
|
|
470
|
+
}
|
|
471
|
+
return err(last);
|
|
472
|
+
}
|
|
473
|
+
function forgetComputer() {
|
|
474
|
+
const cfg = loadConfig(); delete cfg.computer; delete cfg.computerSecret; saveConfig(cfg); if (ANDROID_APP) writeSecret(null);
|
|
475
|
+
for (const f of [PATHS.copy, PATHS.queue]) try { unlinkSync(f); } catch {}
|
|
476
|
+
pollErr = ""; pollLast = 0; fails = 0; lastVia = ""; clearTimeout(pollTimer); pollTimer = null;
|
|
135
477
|
return computerState();
|
|
136
478
|
}
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
async function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
479
|
+
// Where the paired computer is now, by its key's fingerprint: the Android app asks
|
|
480
|
+
// Android's own discovery (the service runs it: android-nsd.want, then .json),
|
|
481
|
+
// anything else asks the network itself (mdns.mjs).
|
|
482
|
+
async function discoverComputer(c) {
|
|
483
|
+
return (await findComputers()).filter((f) => f.fp === c.fp).map((f) => f.url);
|
|
484
|
+
}
|
|
485
|
+
// Every Symbiot saying it's here on this network, for the pairing screen: [{ name, url, fp }].
|
|
486
|
+
async function findComputers() {
|
|
487
|
+
let found = [];
|
|
488
|
+
if (ANDROID_APP) {
|
|
489
|
+
const asked = Date.now(); writeJson(PATHS.nsdWant, { at: asked });
|
|
490
|
+
for (let i = 0; i < 16; i++) {
|
|
491
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
492
|
+
try { if (statSync(PATHS.nsd).mtimeMs >= asked - 1000) { found = readJson(PATHS.nsd, []); break; } } catch {}
|
|
493
|
+
}
|
|
494
|
+
} else found = await browse({ timeout: 2000 }).catch(() => []);
|
|
495
|
+
return (Array.isArray(found) ? found : []).filter((f) => f && f.port).flatMap((f) => (f.addresses || [f.host]).filter(Boolean).map((a) => ({ name: String(f.name || "Symbiot").slice(0, 40), url: computerUrl(`${a}:${f.port}`), fp: String(f.fp || "") }))).filter((f) => f.url);
|
|
496
|
+
}
|
|
497
|
+
// One sealed request to the paired computer, wherever it is: the address that last
|
|
498
|
+
// answered, the others it gave at pairing, wherever it says it is now (mDNS), and
|
|
499
|
+
// only then the relay. { status, body, via } or { unreachable: true }.
|
|
500
|
+
async function ask(op, args, { fetchFn = fetch, discover = discoverComputer } = {}) {
|
|
501
|
+
const c = loadConfig().computer, s = readSecret();
|
|
502
|
+
if (!c || !s) return { status: 0, body: { error: OLD_PAIRING } };
|
|
503
|
+
const key = Buffer.from(s.key, "base64url");
|
|
504
|
+
const make = () => ({ v: 1, id: c.id, ...seal(key, { t: s.token, ts: Date.now() + (Number(c.offset) || 0), op, args }, "req:" + c.id) });
|
|
505
|
+
const wait = op === "apply" ? 30000 : 6000, tried = new Set();
|
|
506
|
+
const viaUrl = async (url) => { const env = make(), r = await fetchFn(`${url}/phone/${op}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(wait) }); return { env, status: r.status, json: await r.json().catch(() => ({})), via: url }; };
|
|
507
|
+
let res = null;
|
|
508
|
+
for (const url of [c.url, ...(c.urls || [])]) { if (!url || tried.has(url)) continue; tried.add(url); try { res = await viaUrl(url); break; } catch {} }
|
|
509
|
+
if (!res) for (const url of await discover(c).catch(() => [])) { if (tried.has(url)) continue; tried.add(url); try { res = await viaUrl(url); break; } catch {} }
|
|
510
|
+
if (!res && c.relay && !c.relayOff && relayAllowed()) {
|
|
511
|
+
try {
|
|
512
|
+
const env = make(), r = await fetchFn(`${String(process.env.SYMBIOT_RELAY || c.relayUrl || relayBase()).replace(/\/+$/, "")}/v1/${c.relay}/ask`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(wait + 5000) });
|
|
513
|
+
if (r.status === 200) { const j = await r.json(); res = { env, status: Number(j.s) || 0, json: j.b || {}, via: "relay" }; }
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
if (!res) return { unreachable: true };
|
|
517
|
+
if (res.via !== "relay" && res.via !== c.url) { const cfg = loadConfig(); if (cfg.computer && cfg.computer.id === c.id) { cfg.computer.url = res.via; cfg.computer.urls = [...new Set([res.via, ...(cfg.computer.urls || [])])].slice(0, 6); saveConfig(cfg); } }
|
|
518
|
+
const j = res.json || {};
|
|
519
|
+
if (j.n && j.c) { const m = unseal(key, j, "res:" + res.env.n); return m ? { status: Number(m.s) || 0, body: m.b || {}, sealed: true, via: res.via } : { status: 0, body: { error: "Your computer's answer couldn't be read." }, via: res.via }; }
|
|
520
|
+
return { status: res.status, body: j, sealed: false, via: res.via };
|
|
521
|
+
}
|
|
522
|
+
// The copy from the computer, with what you changed here that hasn't reached it yet
|
|
523
|
+
// laid over it ("waiting to send"), and what couldn't be applied there.
|
|
524
|
+
function saveCopy(snap, extra = {}) { const old = loadCopy() || {}; writeJson(PATHS.copy, { ...snap, got: Date.now(), notes: extra.notes !== undefined ? extra.notes : old.notes || [] }); }
|
|
525
|
+
function computerView() {
|
|
526
|
+
const st = computerState(), copy = loadCopy(), q = loadQueue();
|
|
527
|
+
if (!copy) return { ...st, copy: null };
|
|
528
|
+
const v = JSON.parse(JSON.stringify(copy));
|
|
529
|
+
for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes"]) if (!Array.isArray(v[k])) v[k] = [];
|
|
530
|
+
for (const o of q) {
|
|
531
|
+
const a = o.args || {};
|
|
532
|
+
if (o.op === "task.add") v.tasks.unshift({ id: "q:" + o.id, text: plainText(a.text), repo: a.repo || "", done: false, waiting: true });
|
|
533
|
+
if (o.op === "task.setDone") { const t = v.tasks.find((x) => x.id === a.id); if (t) { t.done = !!a.done; t.waiting = true; } }
|
|
534
|
+
if (o.op === "agent.answer") { const x = v.asks.find((y) => y.path === a.path); if (x) { x.waiting = true; x.answered = (a.answers || []).map((y) => y.a); } }
|
|
535
|
+
if (o.op === "pending.approve") { const x = v.approves.find((y) => y.repo === a.repo); if (x) x.waiting = true; }
|
|
536
|
+
}
|
|
537
|
+
return { ...st, copy: v };
|
|
538
|
+
}
|
|
539
|
+
// A change made here: kept in order, shown at once, sent as soon as the computer answers.
|
|
540
|
+
// (refused: why it wasn't kept; error stays what the last ask of the computer said)
|
|
541
|
+
function queueChange(op, args = {}) {
|
|
542
|
+
const c = loadConfig().computer; if (!c) return { ...computerState(), refused: "Pair with your computer first." };
|
|
543
|
+
if (!PHONE_OPS.includes(op)) return { ...computerView(), refused: "That can't be done from here." };
|
|
544
|
+
const a = {};
|
|
545
|
+
if (op === "task.add") { a.text = String(args.text || "").trim().slice(0, 4000); a.repo = String(args.repo || ""); if (!a.text) return { ...computerView(), refused: "Type the task first." }; }
|
|
546
|
+
if (op === "task.setDone") { a.id = String(args.id || ""); a.done = !!args.done; }
|
|
547
|
+
if (op === "agent.answer") { a.path = String(args.path || ""); a.answers = (Array.isArray(args.answers) ? args.answers : []).slice(0, 5).map((x) => ({ q: String((x && x.q) || ""), a: String((x && x.a) || "").slice(0, 2000) })).filter((x) => x.q && x.a); if (!a.answers.length) return { ...computerView(), refused: "Pick or type an answer first." }; }
|
|
548
|
+
if (op === "pending.approve") { a.repo = String(args.repo || ""); a.sum = String(args.sum || ""); }
|
|
549
|
+
const q = loadQueue();
|
|
550
|
+
if (op === "task.setDone") for (let i = q.length - 1; i >= 0; i--) if (q[i].op === "task.setDone" && q[i].args.id === a.id) q.splice(i, 1); // the last tick wins
|
|
551
|
+
q.push({ id: randomUUID(), at: Date.now() + (Number(c.offset) || 0), op, args: a }); saveQueue(q);
|
|
552
|
+
if (process.env.SYMBIOT_NO_POLL !== "1") setTimeout(() => { pollComputer().catch(() => {}); }, 30).unref();
|
|
553
|
+
return computerView();
|
|
554
|
+
}
|
|
555
|
+
function dismissNote(id) { const c = loadCopy(); if (c) saveCopy(c, { notes: (c.notes || []).filter((n) => n.id !== id) }); return computerView(); }
|
|
556
|
+
// Ask the computer: send what was queued here, then what's new (notified, one
|
|
557
|
+
// notification per watch per read), then the copy (unchanged costs nothing).
|
|
558
|
+
async function pollComputer(opts = {}) { if (polling) return polling; polling = pollOnce(opts).finally(() => { polling = null; schedulePoll(); }); return polling; }
|
|
559
|
+
async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } = {}) {
|
|
560
|
+
const c = loadConfig().computer; if (!c) return { ...computerState(), shown: 0 };
|
|
561
|
+
if (!readSecret()) return { ...computerState(), shown: 0 };
|
|
562
|
+
const via = { fetchFn, ...(discover ? { discover } : {}) };
|
|
563
|
+
const away = () => { fails++; pollErr = `Couldn't reach ${c.name}${fails > 1 ? "" : `. It's asked again in ${BACKOFF[0] / 1000} seconds`} (it may be off or asleep${c.relay ? "" : ", or on another network"}).`; return { ...computerState(), shown: 0 }; };
|
|
564
|
+
const refused = (r) => {
|
|
565
|
+
pollErr = (r.body && r.body.error) || `${c.name} answered ${r.status}.`; fails = 0;
|
|
566
|
+
// unpaired there: an approve it had queued never goes, even if it's paired again later
|
|
567
|
+
if (r.sealed && r.body && r.body.unpaired) {
|
|
568
|
+
const q = loadQueue(), gone = q.filter((o) => o.op === "pending.approve");
|
|
569
|
+
if (gone.length) { saveQueue(q.filter((o) => o.op !== "pending.approve")); const copy = loadCopy(); if (copy) saveCopy(copy, { notes: [...(copy.notes || []), ...gone.map((o) => ({ id: o.id, op: o.op, what: o.args.repo, error: `Not approved: this phone was unpaired on ${c.name}.`, at: Date.now() }))].slice(-20) }); }
|
|
570
|
+
}
|
|
571
|
+
return { ...computerState(), shown: 0 };
|
|
572
|
+
};
|
|
573
|
+
const q = loadQueue();
|
|
574
|
+
if (q.length) {
|
|
575
|
+
const r = await ask("apply", { ops: q }, via);
|
|
576
|
+
if (r.unreachable) return away();
|
|
577
|
+
if (r.status !== 200) return refused(r);
|
|
578
|
+
const done = new Set((r.body.results || []).map((x) => x.id)), failed = (r.body.results || []).filter((x) => !x.ok && x.error);
|
|
579
|
+
saveQueue(loadQueue().filter((o) => !done.has(o.id)));
|
|
580
|
+
const notes = [...((loadCopy() || {}).notes || []), ...failed.map((x) => { const o = q.find((y) => y.id === x.id) || {}; return { id: x.id, op: o.op, what: plainText((o.args && (o.args.text || o.args.repo)) || "", 80), error: x.error, at: Date.now() }; })].slice(-20);
|
|
581
|
+
if (r.body.state && r.body.state.v) saveCopy(r.body.state, { notes });
|
|
582
|
+
}
|
|
583
|
+
const r = await ask("news", { since: c.since || 0 }, via);
|
|
584
|
+
if (r.unreachable) return away();
|
|
585
|
+
if (r.status !== 200) return refused(r);
|
|
586
|
+
pollErr = ""; fails = 0; pollLast = Date.now(); lastVia = r.via === "relay" ? "relay" : "wifi";
|
|
587
|
+
const news = Array.isArray(r.body.news) ? r.body.news : [], briefs = Array.isArray(r.body.briefs) ? r.body.briefs : [];
|
|
149
588
|
const batches = new Map();
|
|
150
589
|
for (const n of news) { const k = n.watch + " " + n.ts; if (!batches.has(k)) batches.set(k, []); batches.get(k).push(n); }
|
|
151
590
|
for (const [k, list] of batches) { const b = briefs.find((x) => x.watch + " " + x.ts === k); notify(...newsNotice(list, list[0].name, b ? b.text : "")); }
|
|
152
591
|
if (news.length) { const cfg = loadConfig(); if (cfg.computer) { cfg.computer.since = Math.max(Number(cfg.computer.since) || 0, ...news.map((n) => Number(n.ts) || 0)); saveConfig(cfg); } }
|
|
592
|
+
const copy = loadCopy(), st = await ask("state", { etag: (copy && copy.v) || "" }, via);
|
|
593
|
+
if (st.status === 200 && st.body && st.body.v) { saveCopy(st.body); const cfg = loadConfig(); if (cfg.computer && !!cfg.computer.relayOff !== (st.body.relay === false)) { cfg.computer.relayOff = st.body.relay === false; saveConfig(cfg); } }
|
|
594
|
+
else if (st.status === 304 && copy) saveCopy(copy);
|
|
153
595
|
return { ...computerState(), shown: batches.size };
|
|
154
596
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
597
|
+
// Every 2 minutes; after a failed ask sooner (15 s, 30 s, a minute), so a phone
|
|
598
|
+
// that changed networks or a computer that woke is back without a wait.
|
|
599
|
+
function schedulePoll(wait) {
|
|
600
|
+
if (!(loadConfig().computer || {}).id || process.env.SYMBIOT_NO_POLL === "1") return;
|
|
601
|
+
clearTimeout(pollTimer);
|
|
602
|
+
pollTimer = setTimeout(() => { pollTimer = null; pollComputer().catch(() => {}); }, wait !== undefined ? wait : fails ? BACKOFF[Math.min(fails, BACKOFF.length + 1) - 1] || POLL_MS : POLL_MS);
|
|
603
|
+
pollTimer.unref();
|
|
160
604
|
}
|
|
605
|
+
function startComputerPoll({ soon = false } = {}) { if (pollTimer && !soon) return; schedulePoll(soon ? 1500 : 15000); }
|
|
161
606
|
|
|
162
|
-
// ---- both
|
|
163
|
-
const phoneState = () => (PHONE ?
|
|
607
|
+
// ---- both ------------------------------------------------------------------------------------
|
|
608
|
+
const phoneState = () => (PHONE ? computerView() : linkState());
|
|
164
609
|
// While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
|
|
165
610
|
function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
|
|
166
611
|
|
|
167
|
-
export { PORT,
|
|
612
|
+
export { PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
|
|
613
|
+
computerUrl, parsePairLink, pairLink, computerState, computerView, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
|
|
614
|
+
newKeys, linkKey, fingerprint, seal, unseal, relayOf, answer };
|