agent-dag 3.20.0 → 3.21.1
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/README.md +113 -5
- package/dist/web/assets/{index-DVj_M279.css → index-BYMVIG6f.css} +1 -1
- package/dist/web/assets/index-ByXJWM3T.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +32 -0
- package/src/server/cswap-admin.mjs +87 -11
- package/src/server/deck-prefs.mjs +51 -1
- package/src/server/index.mjs +162 -6
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +90 -7
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/presence.mjs +40 -0
- package/dist/web/assets/index-Dr-bVaHM.js +0 -266
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-ByXJWM3T.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BYMVIG6f.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.1",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.21.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "🖼️ The deck explains itself in pictures",
|
|
37
|
+
"body": "🎬 First run opens an eight-picture tour instead of the changelog — who is waiting on you, the tree, a node's inside, cost and quota, this machine, accounts, local network, how to start. `Take the tour` on an empty canvas brings it back.\n\n📡 **Local network** has a guide: a card while it is off, `How it works` under an empty list, four pictures ending on `Turn it on`. A machine's row opens its own dialog — address, version and system, the logins it offers, and a name of your own for it. Setup ends on Done.\n\n🔄 The deck updates itself when nobody is looking: no tab focused, no agent mid-turn, thirty seconds quiet. The banner's `auto when idle` switch governs it.\n\n🔑 An account whose numbers were paused resumes on its own, and the panel is titled **Claude accounts** — a Codex login is not in it."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
34
40
|
"3.20.0": [
|
|
35
41
|
{
|
|
36
42
|
"title": "🟢 The deck keeps running after you close the terminal",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Whether any agent is mid-turn, from the hook stream alone.
|
|
2
|
+
//
|
|
3
|
+
// For the one decision the server takes on its own that must not land in the
|
|
4
|
+
// middle of a turn: restarting into a new version while nobody is looking
|
|
5
|
+
// (auto-update.mjs). A restart mid-turn drops the hook events fired during the
|
|
6
|
+
// gap, and the canvas shows those tools in flight until the stale sweeper
|
|
7
|
+
// reaps them — restart.ts states the rule for the page, which decides it from
|
|
8
|
+
// its reducer's `active` state.
|
|
9
|
+
//
|
|
10
|
+
// With no page open there is no reducer, so this keeps the smallest version of
|
|
11
|
+
// it: a session is open from the first event of a turn until the turn ends
|
|
12
|
+
// (`Stop`, `SessionEnd`). SessionStart and Notification move nothing — a CLI
|
|
13
|
+
// sitting at its prompt is not busy, and the idle reminder arrives after the
|
|
14
|
+
// turn it is about. An open session whose last event is older than
|
|
15
|
+
// OPEN_CAP_MS counts as gone rather than busy: a CLI that crashed never sends
|
|
16
|
+
// its Stop, and waiting on one forever would mean never updating.
|
|
17
|
+
export const OPEN_CAP_MS = 30 * 60_000;
|
|
18
|
+
|
|
19
|
+
const OPENS = new Set([
|
|
20
|
+
"UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop",
|
|
21
|
+
]);
|
|
22
|
+
const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
23
|
+
|
|
24
|
+
export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
25
|
+
/** session id → when its turn last produced an event */
|
|
26
|
+
const open = new Map();
|
|
27
|
+
let lastAt = 0;
|
|
28
|
+
return {
|
|
29
|
+
/** One hook payload, as pushEvent receives it. Anything else is ignored. */
|
|
30
|
+
note(raw, at) {
|
|
31
|
+
const name = raw?.hook_event_name;
|
|
32
|
+
if (!OPENS.has(name) && !CLOSES.has(name)) return;
|
|
33
|
+
if (at > lastAt) lastAt = at;
|
|
34
|
+
const id = raw?.session_id;
|
|
35
|
+
if (typeof id !== "string" || !id) return;
|
|
36
|
+
if (CLOSES.has(name)) open.delete(id);
|
|
37
|
+
else open.set(id, at);
|
|
38
|
+
},
|
|
39
|
+
/** Whether any turn is still running. */
|
|
40
|
+
busy(now) {
|
|
41
|
+
for (const [id, at] of open) {
|
|
42
|
+
if (Math.abs(now - at) > openCapMs) open.delete(id);
|
|
43
|
+
else return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
},
|
|
47
|
+
/** How long since the last turn event of any session — Infinity before the first. */
|
|
48
|
+
quietMs(now) {
|
|
49
|
+
return lastAt ? Math.max(0, now - lastAt) : Infinity;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Updating the deck while nobody is looking at it.
|
|
2
|
+
//
|
|
3
|
+
// The update path had one automatic step, and it needed a page: the banner's
|
|
4
|
+
// `auto when idle` restarted the deck once an install had landed, and only in a
|
|
5
|
+
// tab somebody was looking at. An install itself never happened unless a person
|
|
6
|
+
// pressed `Update now`, and a deck with no tab open stayed on its old code for
|
|
7
|
+
// good. Since 3.20.0 the deck outlives its terminal and runs for days with no
|
|
8
|
+
// tab at all, which made that the common case.
|
|
9
|
+
//
|
|
10
|
+
// So the server does it, at the one moment it cannot interrupt anybody: no tab
|
|
11
|
+
// has focus (presence.mjs), no agent is mid-turn, and no turn has produced an
|
|
12
|
+
// event for AWAY_QUIET_MS (activity.mjs). The same switch governs it — the
|
|
13
|
+
// banner's `auto when idle`, kept in prefs.json as `autoUpdate` now so the
|
|
14
|
+
// server can read it with no page open. A person looking at the deck still gets
|
|
15
|
+
// the banner and its buttons; this is only for when they are not.
|
|
16
|
+
//
|
|
17
|
+
// Every effect goes through the code a press uses — startUpgrade, and the
|
|
18
|
+
// launcher's restart — so an unattended update cannot do anything a person
|
|
19
|
+
// could not have done from the banner. Pure rules here; index.mjs owns the
|
|
20
|
+
// timer and the effects.
|
|
21
|
+
|
|
22
|
+
/** Quiet before the deck may act: the page's IDLE_BEFORE_RESTART_MS, so the
|
|
23
|
+
* two paths agree on what idle means. */
|
|
24
|
+
export const AWAY_QUIET_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
/** How long a deck that has just started waits before acting at all. A
|
|
27
|
+
* relaunch that came back on the version it left would otherwise try again a
|
|
28
|
+
* minute later, and again after that; this caps any such loop at one attempt
|
|
29
|
+
* per boot grace, under the launcher's own two-strike rule for npx
|
|
30
|
+
* (supervisor.mjs, upgradeAttempt). */
|
|
31
|
+
export const AWAY_BOOT_GRACE_MS = 5 * 60_000;
|
|
32
|
+
|
|
33
|
+
/** How long the same attempt is left alone after it was made: an install that
|
|
34
|
+
* failed, a fetch the launcher refused, a restart it could not run. A newer
|
|
35
|
+
* version is a different attempt and is not held back by this. */
|
|
36
|
+
export const AWAY_RETRY_MS = 30 * 60_000;
|
|
37
|
+
|
|
38
|
+
/** How often the server asks. The gate below is answered from memory; only
|
|
39
|
+
* when it passes does the tick read the version report, which touches disk. */
|
|
40
|
+
export const AWAY_TICK_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
/** How long a report that found nothing newer is taken as the answer. The npm
|
|
43
|
+
* lookup behind it is hourly anyway, and the report is not free: on Windows
|
|
44
|
+
* upgradeBlock proves the npm prefix writable by creating a file in it, and a
|
|
45
|
+
* deck left alone overnight would otherwise do that once a minute. */
|
|
46
|
+
export const AWAY_RECHECK_MS = 5 * 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the deck may consider updating itself right now. Every input is
|
|
50
|
+
* already in memory, so this runs every tick at no cost.
|
|
51
|
+
*/
|
|
52
|
+
export function awayGate({
|
|
53
|
+
enabled, supervised, restarting, sinceBootMs, looking, busy, quietMs,
|
|
54
|
+
graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
|
|
55
|
+
}) {
|
|
56
|
+
return enabled === true && supervised === true && !restarting
|
|
57
|
+
&& sinceBootMs >= graceMs && !looking && !busy && quietMs >= quietNeedMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What to do about the version report, once the gate has passed.
|
|
62
|
+
*
|
|
63
|
+
* restart the newer code is already on disk (an install landed, or a
|
|
64
|
+
* checkout was pulled) — the free half, as pickNotice says
|
|
65
|
+
* install `npm i -g` over a global install; the next tick restarts into it
|
|
66
|
+
* npx the launcher fetches the new version and hands it the port
|
|
67
|
+
* null nothing newer, an install already running, the same attempt
|
|
68
|
+
* made too recently, or a copy that only a person can update
|
|
69
|
+
*/
|
|
70
|
+
export function awayUpdateStep({ notice, mode, installing, lastTry, now, retryMs = AWAY_RETRY_MS }) {
|
|
71
|
+
if (!notice || installing) return { act: null };
|
|
72
|
+
const target = `${notice.kind}:${notice.to}`;
|
|
73
|
+
// A clock that moved backwards counts as time elapsed, as everywhere else in
|
|
74
|
+
// the update path: a deck must not wait out a window it cannot measure.
|
|
75
|
+
if (lastTry?.target === target && now >= lastTry.at && now - lastTry.at < retryMs) return { act: null, target };
|
|
76
|
+
if (notice.kind === "restart") return { act: "restart", target };
|
|
77
|
+
if (notice.kind === "upgrade" && (mode === "install" || mode === "npx")) return { act: mode, target };
|
|
78
|
+
return { act: null, target };
|
|
79
|
+
}
|
|
@@ -504,6 +504,34 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
|
|
|
504
504
|
return mine;
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
// ── the repair nobody should have to press ───────────────────────────────────
|
|
508
|
+
//
|
|
509
|
+
// A `stale-copy` row — claude-swap's stored copy of the signed-in account's
|
|
510
|
+
// login was rejected, while the login itself works — has exactly one repair,
|
|
511
|
+
// and it asks the user nothing: re-capture the copy from the login they already
|
|
512
|
+
// have (cswap-admin's recaptureActive). It was a button, `resume`, which asked a
|
|
513
|
+
// person to confirm the only answer there is. So the read that finds the state
|
|
514
|
+
// starts the repair, and the row carries how it is going instead of a button.
|
|
515
|
+
//
|
|
516
|
+
// HANDED IN, NOT IMPORTED. The repair writes a credential into claude-swap's
|
|
517
|
+
// store, and this module is read by dozens of tests against fixture stores. The
|
|
518
|
+
// server hands it in when it starts listening (index.mjs) and nothing else does,
|
|
519
|
+
// so no test run can reach `cswap add` through a read.
|
|
520
|
+
let _repairStaleCopy = null;
|
|
521
|
+
|
|
522
|
+
/** Called with `{ num, email, now }` for a `stale-copy` row, and returns that
|
|
523
|
+
* row's `repair`. Null unregisters. */
|
|
524
|
+
export function repairStaleCopyWith(fn) {
|
|
525
|
+
_repairStaleCopy = typeof fn === "function" ? fn : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The registered repair's state for one row. A repair that throws costs the
|
|
529
|
+
* row its state, never the read. */
|
|
530
|
+
function repairFor(num, email, now) {
|
|
531
|
+
if (!_repairStaleCopy) return null;
|
|
532
|
+
try { return _repairStaleCopy({ num, email, now }) ?? null; } catch { return null; }
|
|
533
|
+
}
|
|
534
|
+
|
|
507
535
|
/**
|
|
508
536
|
* The read itself, split out from the admission control above it so the guard is
|
|
509
537
|
* readable as the four lines it is.
|
|
@@ -636,6 +664,10 @@ async function readRoster(now, gen) {
|
|
|
636
664
|
// in as it anyway. The panel says so quietly instead of offering to log
|
|
637
665
|
// them in again.
|
|
638
666
|
staleCopy: trouble?.kind === "stale-copy",
|
|
667
|
+
// How the deck's own repair of that is going — `{ state: "running" }` or
|
|
668
|
+
// `{ state: "failed", reason, retryAt }` — and null on every other row.
|
|
669
|
+
// Asking is what starts it; see repairStaleCopyWith.
|
|
670
|
+
repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
|
|
639
671
|
// Nothing has been collected for this account in half a day, and nothing
|
|
640
672
|
// says why. Its own word because the two existing ones would both be
|
|
641
673
|
// wrong: `error` claims a rejection that was never reported, and
|
|
@@ -1216,26 +1216,39 @@ export function removePromptMatches(line, num) {
|
|
|
1216
1216
|
* refresh token dies it quarantines the row: no further collection is
|
|
1217
1217
|
* attempted, so the numbers freeze and every Refresh re-reads a store that
|
|
1218
1218
|
* cannot change. #721 fixed the sentence the panel said about that state; this
|
|
1219
|
-
* is the
|
|
1219
|
+
* is what ends it — started by the deck itself (autoRecapture, below) the
|
|
1220
|
+
* moment a read finds the state, and still reachable from the admin route.
|
|
1220
1221
|
*
|
|
1221
1222
|
* `cswap add` on an account already in the store is an idempotent credential
|
|
1222
1223
|
* refresh — registerSignedIn above says so in its own words: "No new slot means
|
|
1223
1224
|
* the account was already managed and cswap refreshed its credentials in
|
|
1224
|
-
* place." It captures whatever is signed in RIGHT NOW,
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
* account does not change.
|
|
1225
|
+
* place." It captures whatever is signed in RIGHT NOW, with the working
|
|
1226
|
+
* credentials the user already has. Nobody is signed in or out, no browser
|
|
1227
|
+
* opens, and the active account does not change.
|
|
1228
1228
|
*
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1229
|
+
* WHICH IS WHY IT ASKS WHO THAT IS FIRST. "Whatever is signed in" is the active
|
|
1230
|
+
* slot's account only while the two agree. Somebody who signed in as another
|
|
1231
|
+
* account in a terminal has made it a different one, and `cswap add` would then
|
|
1232
|
+
* refresh — or add — that account instead. Behind a press that was a narrow
|
|
1233
|
+
* race; with the deck starting it on its own it is the one thing that must be
|
|
1234
|
+
* ruled out. So the capture runs only when the CLI's account is the active
|
|
1235
|
+
* slot's, and, when the caller names an account, that one too.
|
|
1231
1236
|
*/
|
|
1232
|
-
export async function recaptureActive() {
|
|
1237
|
+
export async function recaptureActive({ expect = null } = {}) {
|
|
1233
1238
|
return withStoreLock(async () => {
|
|
1234
|
-
// Who is
|
|
1235
|
-
// captures the live credentials and this is the one check that the thing it
|
|
1236
|
-
// captured is the thing the user meant.
|
|
1239
|
+
// Who is signed in right now: `cswap add` captures THAT, whoever it is.
|
|
1237
1240
|
const before = await currentIdentity();
|
|
1238
1241
|
if (!before?.email) return { ok: false, reason: "not_signed_in" };
|
|
1242
|
+
const who = before.email.toLowerCase();
|
|
1243
|
+
const store = await readStore();
|
|
1244
|
+
const activeNum = String(store.activeNum);
|
|
1245
|
+
const activeEmail = String(store.emails[activeNum] ?? "").toLowerCase();
|
|
1246
|
+
// One address under two organizations is two accounts to claude-swap, so a
|
|
1247
|
+
// matching email in another org is still somebody else's slot.
|
|
1248
|
+
const sameOrg = !before.orgId || !store.orgs[activeNum] || before.orgId === store.orgs[activeNum];
|
|
1249
|
+
if (who !== activeEmail || !sameOrg || (expect != null && who !== String(expect).toLowerCase())) {
|
|
1250
|
+
return { ok: false, reason: "not_active_account", email: before.email };
|
|
1251
|
+
}
|
|
1239
1252
|
|
|
1240
1253
|
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
1241
1254
|
if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
|
|
@@ -1259,6 +1272,69 @@ export async function recaptureActive() {
|
|
|
1259
1272
|
});
|
|
1260
1273
|
}
|
|
1261
1274
|
|
|
1275
|
+
// ── the same repair, without the press ──────────────────────────────────────
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* How long one account is left alone after the deck re-captured it on its own.
|
|
1279
|
+
*
|
|
1280
|
+
* Ten minutes: past claude-swap's longest ordinary poll interval, so a capture
|
|
1281
|
+
* that took has been collected before this could fire again, and one that did
|
|
1282
|
+
* not — a collector that still cannot read the login — costs one `claude auth
|
|
1283
|
+
* status` and one `cswap add` per ten minutes rather than one per panel poll.
|
|
1284
|
+
*/
|
|
1285
|
+
const AUTO_RECAPTURE_RETRY_MS = 10 * 60_000;
|
|
1286
|
+
|
|
1287
|
+
/** Emails whose automatic attempt is under way. Per account rather than one
|
|
1288
|
+
* flag: after an auto-switch a second account can pause while the first's
|
|
1289
|
+
* attempt still waits on the store lock, and it must not borrow that attempt's
|
|
1290
|
+
* `running`. The lock serialises the attempts themselves. */
|
|
1291
|
+
const _autoRunning = new Set();
|
|
1292
|
+
/** Lower-cased email → the last automatic attempt: when, and why it failed. */
|
|
1293
|
+
const _autoTried = new Map();
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* What the deck's own re-capture of a paused account should do now, given its
|
|
1297
|
+
* last attempt. Pure; exported for tests.
|
|
1298
|
+
*
|
|
1299
|
+
* running an attempt is under way
|
|
1300
|
+
* start none has been made, or the last is old enough to try again
|
|
1301
|
+
* failed one was made inside the window and the row is still paused;
|
|
1302
|
+
* `reason` is why it failed, or null when the capture itself held
|
|
1303
|
+
*/
|
|
1304
|
+
export function autoRecaptureState(last, now, { inFlight = false, retryMs = AUTO_RECAPTURE_RETRY_MS } = {}) {
|
|
1305
|
+
if (inFlight) return { state: "running" };
|
|
1306
|
+
if (!last || now - last.at >= retryMs) return { state: "start" };
|
|
1307
|
+
return { state: "failed", reason: last.reason ?? null, retryAt: last.at + retryMs };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Re-capture a paused account because a read found it paused — the repair the
|
|
1312
|
+
* `resume` button used to wait for.
|
|
1313
|
+
*
|
|
1314
|
+
* Synchronous for its caller, which is building a roster row: it starts the
|
|
1315
|
+
* attempt and returns the state to draw. At most one attempt per account under
|
|
1316
|
+
* way and one per AUTO_RECAPTURE_RETRY_MS, always for the account the row names,
|
|
1317
|
+
* which recaptureActive checks against who is signed in inside the store lock.
|
|
1318
|
+
*/
|
|
1319
|
+
export function autoRecapture({ email, now = Date.now() } = {}) {
|
|
1320
|
+
const key = String(email ?? "").trim().toLowerCase();
|
|
1321
|
+
if (!key) return null;
|
|
1322
|
+
const next = autoRecaptureState(_autoTried.get(key), now, { inFlight: _autoRunning.has(key) });
|
|
1323
|
+
if (next.state !== "start") return next;
|
|
1324
|
+
_autoRunning.add(key);
|
|
1325
|
+
_autoTried.set(key, { at: now, reason: null });
|
|
1326
|
+
recaptureActive({ expect: key })
|
|
1327
|
+
.then(
|
|
1328
|
+
r => { if (!r?.ok) _autoTried.set(key, { at: now, reason: r?.reason ?? "failed" }); },
|
|
1329
|
+
() => { _autoTried.set(key, { at: now, reason: "failed" }); },
|
|
1330
|
+
)
|
|
1331
|
+
// And the roster forgets the row it drew as `running`, so the next read —
|
|
1332
|
+
// forced or not — says how the attempt went, instead of the cache or a
|
|
1333
|
+
// forced read's one-minute floor holding `resuming…` over a failure.
|
|
1334
|
+
.finally(() => { _autoRunning.delete(key); invalidateClaudeAccountsCache(); });
|
|
1335
|
+
return { state: "running" };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1262
1338
|
export async function removeAccount(num) {
|
|
1263
1339
|
const n = Number(num);
|
|
1264
1340
|
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
@@ -58,6 +58,12 @@ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.j
|
|
|
58
58
|
*/
|
|
59
59
|
export const DEFAULTS = Object.freeze({
|
|
60
60
|
notifications: true,
|
|
61
|
+
// Whether the deck may update itself: restart into code already on disk once
|
|
62
|
+
// it is idle, and — while nobody is looking — install a newer release and
|
|
63
|
+
// restart into that (auto-update.mjs). ON, because this is the banner's
|
|
64
|
+
// `auto when idle`, which defaulted on as a localStorage key; it moved here so
|
|
65
|
+
// the server can read it with no page open.
|
|
66
|
+
autoUpdate: true,
|
|
61
67
|
// LAN sync, off until somebody turns it on. `passphrase` is the only secret
|
|
62
68
|
// this file has ever held, which is why the write below now names a mode.
|
|
63
69
|
lan: Object.freeze({
|
|
@@ -74,6 +80,9 @@ export const DEFAULTS = Object.freeze({
|
|
|
74
80
|
// the dialog that turns the feature on prints them.
|
|
75
81
|
autoAsk: true,
|
|
76
82
|
autoAccept: true,
|
|
83
|
+
// What somebody HERE calls another deck, keyed by its fingerprint. The name
|
|
84
|
+
// a deck gives itself is its owner's to choose; this is the other half.
|
|
85
|
+
aliases: Object.freeze({}),
|
|
77
86
|
}),
|
|
78
87
|
});
|
|
79
88
|
|
|
@@ -90,6 +99,40 @@ export const DEFAULTS = Object.freeze({
|
|
|
90
99
|
* window between the two, which is exactly when a secret is in it. */
|
|
91
100
|
export const PREFS_MODE = 0o600;
|
|
92
101
|
|
|
102
|
+
/** The longest name somebody here may give another deck. The same order as a
|
|
103
|
+
* deck's own name, and short enough to stay one line in the panel's column. */
|
|
104
|
+
export const ALIAS_MAX = 48;
|
|
105
|
+
|
|
106
|
+
/** A name somebody typed for another deck, cleaned — or "" for one with
|
|
107
|
+
* nothing left in it, which is how an alias is taken away. Control characters
|
|
108
|
+
* go the way cleanName sends them: this string reaches a terminal as well as a
|
|
109
|
+
* page. */
|
|
110
|
+
export function cleanAlias(raw) {
|
|
111
|
+
if (typeof raw !== "string") return "";
|
|
112
|
+
const flat = raw.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
113
|
+
return [...flat].slice(0, ALIAS_MAX).join("").trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A fingerprint an alias may be kept under. Real ones are `abc-def-012-345`;
|
|
117
|
+
* what matters here is only that a typed address's placeholder — which has a
|
|
118
|
+
* colon in it and names no deck — can never be one, and that nothing exotic
|
|
119
|
+
* becomes a key in an object this file writes back to disk. */
|
|
120
|
+
export function isAliasKey(fp) {
|
|
121
|
+
return typeof fp === "string" && /^[A-Za-z0-9-]{1,64}$/.test(fp);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The alias map, coerced. A page writes it; a hand-edited file can hold
|
|
125
|
+
* anything. Capped, because nothing legitimate is anywhere near it. */
|
|
126
|
+
function aliasesFrom(raw) {
|
|
127
|
+
const out = {};
|
|
128
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
129
|
+
for (const [fp, name] of Object.entries(raw).slice(0, 256)) {
|
|
130
|
+
const clean = cleanAlias(name);
|
|
131
|
+
if (isAliasKey(fp) && clean) out[fp] = clean;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
/** One LAN section, coerced. Unknown keys dropped like everything else here,
|
|
94
137
|
* and the two lists forced to arrays of strings — they arrive from a page and
|
|
95
138
|
* are then compared against account keys and dialled as addresses. */
|
|
@@ -113,7 +156,13 @@ function normaliseLan(raw) {
|
|
|
113
156
|
// cannot be checked is worse than no entry at all.
|
|
114
157
|
trusted: (Array.isArray(src.trusted) ? src.trusted : [])
|
|
115
158
|
.filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
|
|
116
|
-
.map(t => ({
|
|
159
|
+
.map(t => ({
|
|
160
|
+
fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "",
|
|
161
|
+
// When somebody here said yes. Absent on every pin made before this was
|
|
162
|
+
// kept, and absent is drawn as "before" rather than guessed.
|
|
163
|
+
...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
|
|
164
|
+
})),
|
|
165
|
+
aliases: aliasesFrom(src.aliases),
|
|
117
166
|
// The port this deck listened on last time, so an address somebody typed on
|
|
118
167
|
// the other machine still works after a restart. It asked the OS for a new
|
|
119
168
|
// one every start, which is invisible while broadcast works and is exactly
|
|
@@ -145,6 +194,7 @@ export function normalise(raw) {
|
|
|
145
194
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
146
195
|
return {
|
|
147
196
|
notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
|
|
197
|
+
autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
|
|
148
198
|
lan: normaliseLan(src.lan),
|
|
149
199
|
};
|
|
150
200
|
}
|