ccdeck 3.29.11 → 3.31.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/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
package/src/server/lan-reach.mjs
CHANGED
|
@@ -212,6 +212,67 @@ export function fixSteps({ category, alias, exePath, rules = [] }) {
|
|
|
212
212
|
return steps;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Whether other decks can reach this Windows machine, from what the probe read:
|
|
217
|
+
* the network the deck is on, the firewall profile that network answers to,
|
|
218
|
+
* and the rules for this program.
|
|
219
|
+
*
|
|
220
|
+
* Null for a probe that did not come back, for a network that is not Public,
|
|
221
|
+
* Private or Domain, and for rules that could not be read on a network that is
|
|
222
|
+
* not Public — see below for why that last one is silence rather than a guess.
|
|
223
|
+
*/
|
|
224
|
+
function windowsReach({ probe = null, aliases = [], exePath = "", inbound = null } = {}) {
|
|
225
|
+
if (!probe) return null;
|
|
226
|
+
// The broadcast route first, then the interfaces the deck holds an address
|
|
227
|
+
// on. The routing table is the authority on where a beacon goes; the deck's
|
|
228
|
+
// own addresses are the fallback for a machine where that read came back
|
|
229
|
+
// empty, and the probe's first connected network is the fallback for that.
|
|
230
|
+
const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
|
|
231
|
+
const category = net?.category ?? "";
|
|
232
|
+
const name = profileFor(category);
|
|
233
|
+
if (!name) return null;
|
|
234
|
+
// MEASURED BEATS READ, here as on Linux: a connection from another machine
|
|
235
|
+
// has arrived on the sync listener, so the path is open whatever rule this
|
|
236
|
+
// was about to fail to find. See inboundAt in lan-inbound.mjs.
|
|
237
|
+
if (inbound) return { blocked: false, why: "inbound seen", category, alias: net?.alias ?? "" };
|
|
238
|
+
const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
239
|
+
// A firewall that is off blocks nothing, and saying otherwise would send
|
|
240
|
+
// somebody to add a rule that changes nothing.
|
|
241
|
+
if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
|
|
242
|
+
if (ruleCovers(probe.rules, name)) {
|
|
243
|
+
return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
|
|
244
|
+
}
|
|
245
|
+
// COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
|
|
246
|
+
// is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
|
|
247
|
+
// rule would be a guess dressed as a read — and the command that guess hands
|
|
248
|
+
// over adds a duplicate rule on a machine that may already be covered. On a
|
|
249
|
+
// Public network the fix is the category, which was read reliably, so that
|
|
250
|
+
// verdict still stands; anywhere else the honest answer is the measurement,
|
|
251
|
+
// which `refreshReach` falls back to when this returns null.
|
|
252
|
+
// Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
|
|
253
|
+
// whatever the flag says.
|
|
254
|
+
if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
|
|
255
|
+
return {
|
|
256
|
+
blocked: true,
|
|
257
|
+
why: "no inbound rule",
|
|
258
|
+
category,
|
|
259
|
+
alias: net?.alias ?? "",
|
|
260
|
+
// Which shell the steps are written in, so the panel says where to paste
|
|
261
|
+
// them without asking what platform it is drawing for. Every other field
|
|
262
|
+
// here is already the verdict's to choose; this is one more.
|
|
263
|
+
shell: "powershell",
|
|
264
|
+
// The reason in the reader's terms, not the registry's. What they need to
|
|
265
|
+
// know is which half is broken, because the other half is what makes the
|
|
266
|
+
// workaround below obvious rather than magic.
|
|
267
|
+
text: name === "Public"
|
|
268
|
+
? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
|
|
269
|
+
: name === "Domain" && probe.rules.length
|
|
270
|
+
? "Windows lets this deck in on Private networks only, and this one is a company (Domain) network, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine."
|
|
271
|
+
: "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
|
|
272
|
+
steps: fixSteps({ category, alias: net?.alias ?? "", exePath, rules: probe.rules }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
215
276
|
// ── linux ───────────────────────────────────────────────────────────────────
|
|
216
277
|
//
|
|
217
278
|
// THE SAME FAILURE, ONE DISTRIBUTION FURTHER. Reported from a pair of decks on
|
|
@@ -230,8 +291,8 @@ export function fixSteps({ category, alias, exePath, rules = [] }) {
|
|
|
230
291
|
//
|
|
231
292
|
// SO THE MEASUREMENT CARRIES THE VERDICT AND THE CONFIGURATION ONLY RAISES
|
|
232
293
|
// THE QUESTION. `inbound` is when a connection from another machine last
|
|
233
|
-
// arrived on the sync listener (see lan-
|
|
234
|
-
// the path is open and nothing here has an opinion, whatever the rule files
|
|
294
|
+
// arrived on the sync listener (see inboundAt in lan-inbound.mjs). If one ever
|
|
295
|
+
// has, the path is open and nothing here has an opinion, whatever the rule files
|
|
235
296
|
// say. Only when the firewall is on, its inbound default drops, and nothing
|
|
236
297
|
// has ever got in does this speak — and it says what it measured, in those
|
|
237
298
|
// terms, rather than claiming to have read a rule it cannot read.
|
|
@@ -472,75 +533,9 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
|
|
|
472
533
|
if (platform === "linux") return linux ? linuxReach({ ...linux, inbound }) : null;
|
|
473
534
|
if (platform === "darwin") return mac ? macReach({ probe: mac, exePath, inbound }) : null;
|
|
474
535
|
if (platform !== "win32") return null;
|
|
475
|
-
|
|
476
|
-
// The broadcast route first, then the interfaces the deck holds an address
|
|
477
|
-
// on. The routing table is the authority on where a beacon goes; the deck's
|
|
478
|
-
// own addresses are the fallback for a machine where that read came back
|
|
479
|
-
// empty, and the probe's first connected network is the fallback for that.
|
|
480
|
-
const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
|
|
481
|
-
const category = net?.category ?? "";
|
|
482
|
-
const name = profileFor(category);
|
|
483
|
-
if (!name) return null;
|
|
484
|
-
// MEASURED BEATS READ, here as on Linux: a connection from another machine
|
|
485
|
-
// has arrived on the sync listener, so the path is open whatever rule this
|
|
486
|
-
// was about to fail to find. See lan-engine's inboundAt.
|
|
487
|
-
if (inbound) return { blocked: false, why: "inbound seen", category, alias: net?.alias ?? "" };
|
|
488
|
-
const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
489
|
-
// A firewall that is off blocks nothing, and saying otherwise would send
|
|
490
|
-
// somebody to add a rule that changes nothing.
|
|
491
|
-
if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
|
|
492
|
-
if (ruleCovers(probe.rules, name)) {
|
|
493
|
-
return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
|
|
494
|
-
}
|
|
495
|
-
// COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
|
|
496
|
-
// is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
|
|
497
|
-
// rule would be a guess dressed as a read — and the command that guess hands
|
|
498
|
-
// over adds a duplicate rule on a machine that may already be covered. On a
|
|
499
|
-
// Public network the fix is the category, which was read reliably, so that
|
|
500
|
-
// verdict still stands; anywhere else the honest answer is the measurement,
|
|
501
|
-
// which `refreshReach` falls back to when this returns null.
|
|
502
|
-
// Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
|
|
503
|
-
// whatever the flag says.
|
|
504
|
-
if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
|
|
505
|
-
return {
|
|
506
|
-
blocked: true,
|
|
507
|
-
why: "no inbound rule",
|
|
508
|
-
category,
|
|
509
|
-
alias: net?.alias ?? "",
|
|
510
|
-
// Which shell the steps are written in, so the panel says where to paste
|
|
511
|
-
// them without asking what platform it is drawing for. Every other field
|
|
512
|
-
// here is already the verdict's to choose; this is one more.
|
|
513
|
-
shell: "powershell",
|
|
514
|
-
// The reason in the reader's terms, not the registry's. What they need to
|
|
515
|
-
// know is which half is broken, because the other half is what makes the
|
|
516
|
-
// workaround below obvious rather than magic.
|
|
517
|
-
text: name === "Public"
|
|
518
|
-
? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
|
|
519
|
-
: name === "Domain" && probe.rules.length
|
|
520
|
-
? "Windows lets this deck in on Private networks only, and this one is a company (Domain) network, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine."
|
|
521
|
-
: "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
|
|
522
|
-
steps: fixSteps({ category, alias: net?.alias ?? "", exePath, rules: probe.rules }),
|
|
523
|
-
};
|
|
536
|
+
return windowsReach({ probe, aliases, exePath, inbound });
|
|
524
537
|
}
|
|
525
538
|
|
|
526
|
-
/**
|
|
527
|
-
* The way out that needs no rule at all, and the reason this module is not the
|
|
528
|
-
* whole answer.
|
|
529
|
-
*
|
|
530
|
-
* A round is one OUTBOUND connection — `roundWith` dials, both sides prove
|
|
531
|
-
* themselves over that socket, and the manifest and any transfer ride it home.
|
|
532
|
-
* Nothing in a round needs this machine to accept a connection. So a deck that
|
|
533
|
-
* cannot be reached can still do every part of this by dialling first, and the
|
|
534
|
-
* two controls that make it dial are an address somebody typed and an invite
|
|
535
|
-
* somebody else minted.
|
|
536
|
-
*
|
|
537
|
-
* WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
|
|
538
|
-
* An invite carries the addresses of the deck that MINTED it, and the deck that
|
|
539
|
-
* PASTES it dials them. So a blocked machine that mints an invite is asking to
|
|
540
|
-
* be called, which is the one thing it cannot receive; the same pair works on
|
|
541
|
-
* the first try if the invite is minted at the other end and pasted here. Same
|
|
542
|
-
* two controls, opposite order, and only one of the orders works.
|
|
543
|
-
*/
|
|
544
539
|
// ── the machine nothing can be asked about ──────────────────────────────────
|
|
545
540
|
//
|
|
546
541
|
// Every block above depends on the operating system answering a question.
|
|
@@ -598,6 +593,24 @@ export function silentInbound({ heard = 0, listeningSince = null, inbound = null
|
|
|
598
593
|
};
|
|
599
594
|
}
|
|
600
595
|
|
|
596
|
+
/**
|
|
597
|
+
* The way out that needs no rule at all, and the reason this module is not the
|
|
598
|
+
* whole answer.
|
|
599
|
+
*
|
|
600
|
+
* A round is one OUTBOUND connection — `roundWith` dials, both sides prove
|
|
601
|
+
* themselves over that socket, and the manifest and any transfer ride it home.
|
|
602
|
+
* Nothing in a round needs this machine to accept a connection. So a deck that
|
|
603
|
+
* cannot be reached can still do every part of this by dialling first, and the
|
|
604
|
+
* two controls that make it dial are an address somebody typed and an invite
|
|
605
|
+
* somebody else minted.
|
|
606
|
+
*
|
|
607
|
+
* WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
|
|
608
|
+
* An invite carries the addresses of the deck that MINTED it, and the deck that
|
|
609
|
+
* PASTES it dials them. So a blocked machine that mints an invite is asking to
|
|
610
|
+
* be called, which is the one thing it cannot receive; the same pair works on
|
|
611
|
+
* the first try if the invite is minted at the other end and pasted here. Same
|
|
612
|
+
* two controls, opposite order, and only one of the orders works.
|
|
613
|
+
*/
|
|
601
614
|
export function workaround(blocked) {
|
|
602
615
|
if (!blocked) return null;
|
|
603
616
|
return {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The decks waiting on somebody here: the ones that asked, the ones only heard,
|
|
2
|
+
// and the ones somebody told no — and when each is answered for the owner by a
|
|
3
|
+
// switch rather than a press. Lifted out of createEngine in lan-engine.mjs with
|
|
4
|
+
// the three lists only these read and write. What an answer DOES — pinning a
|
|
5
|
+
// key, or putting an address on the dial list — stays the engine's accept;
|
|
6
|
+
// this decides who is on which list and when that accept is pressed.
|
|
7
|
+
|
|
8
|
+
import { pairable } from "./lan-sync.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The two permissions that answer for one route.
|
|
12
|
+
*
|
|
13
|
+
* READ OFF WHICHEVER SETTINGS ARE ASKED ABOUT, because two places ask. A beacon
|
|
14
|
+
* or a request asks of the settings in force; `apply` asks of the settings
|
|
15
|
+
* before a write and after it, to tell which switch the write turned on. The
|
|
16
|
+
* second used to spell the same conditions out again as its own pair, and a
|
|
17
|
+
* switch read one way here and another way there would answer a request in
|
|
18
|
+
* one place and leave it waiting in the other.
|
|
19
|
+
*/
|
|
20
|
+
export const asksOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
|
|
21
|
+
export const saysYesOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `settings` answers the engine's settings in force at the moment of asking,
|
|
25
|
+
* and `engineNow` the engine itself, whose accept is what a switch presses.
|
|
26
|
+
* `routeTo` says whether an address is a tailnet one and whose, `wasUnpaired`
|
|
27
|
+
* whether somebody here unpaired a fingerprint, and `localAddresses` this
|
|
28
|
+
* machine's own addresses. `now` and `onChange` are the engine's own.
|
|
29
|
+
*/
|
|
30
|
+
export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow, onChange, localAddresses }) {
|
|
31
|
+
/**
|
|
32
|
+
* Decks that finished a handshake and that nobody here has accepted yet, and
|
|
33
|
+
* decks merely heard shouting on the network. Two lists because they are two
|
|
34
|
+
* different claims: a pending deck proved it holds the key it announced, a
|
|
35
|
+
* heard one only said so. Both are rows with an accept on them; only the
|
|
36
|
+
* first is evidence.
|
|
37
|
+
*
|
|
38
|
+
* In memory rather than on disk. A request that is a day old is not a request
|
|
39
|
+
* any more, and a list of them that survives restarts is a list nobody reads.
|
|
40
|
+
*/
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
const strangers = new Map();
|
|
43
|
+
/**
|
|
44
|
+
* Decks somebody here said no to.
|
|
45
|
+
*
|
|
46
|
+
* WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
|
|
47
|
+
* that keeps asking — it dials on its own timer, and every dial that finds no
|
|
48
|
+
* pin here becomes a fresh request. So `dismiss` took a row off a list that
|
|
49
|
+
* the next minute put back, and on the other machine the refusal was
|
|
50
|
+
* indistinguishable from a deck that had not been answered yet: both are the
|
|
51
|
+
* same `pending` refusal on the wire, and both drew "waiting for the other
|
|
52
|
+
* deck to accept this one" forever.
|
|
53
|
+
*
|
|
54
|
+
* A name kept here is therefore two answers at once. This deck stops asking
|
|
55
|
+
* its owner, and the deck that asked is TOLD — see refuse("declined") in
|
|
56
|
+
* lan-socket.mjs, which is the only way the far end can ever learn that the
|
|
57
|
+
* answer was no rather than not yet.
|
|
58
|
+
*
|
|
59
|
+
* In memory, like the two lists above, and reversible: `allow` takes a name
|
|
60
|
+
* out and the requests come back. Nothing about it is written down, because a
|
|
61
|
+
* refusal that outlives the process is a decision nobody can find to undo.
|
|
62
|
+
*/
|
|
63
|
+
const declined = new Map();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A real deck nobody here has accepted: draw it as a row with an accept on it.
|
|
67
|
+
*
|
|
68
|
+
* ONE HELPER FOR BOTH DIRECTIONS, because the evidence is the same either
|
|
69
|
+
* way. A deck that DIALLED this one finishes a handshake at the listener and
|
|
70
|
+
* arrives through `onPending`; a deck this one dialled finishes the same
|
|
71
|
+
* handshake in roundWith. Both have proved they hold the key they announced,
|
|
72
|
+
* and neither has been agreed to. Before #969 only the first raised a
|
|
73
|
+
* request and the second silently pinned itself.
|
|
74
|
+
*
|
|
75
|
+
* SAY YES FOR SOMEBODY WHO SAID TO. `autoAccept` is the accept button and
|
|
76
|
+
* nothing else: the same pin, from the same key the handshake just proved.
|
|
77
|
+
* Nothing about the wire changes — an inbound connection is still refused,
|
|
78
|
+
* because trust is read fresh per connection, and the caller comes back a few
|
|
79
|
+
* seconds later.
|
|
80
|
+
*
|
|
81
|
+
* A deck already told no does not become a row again. On the inbound path
|
|
82
|
+
* lan-socket refuses it before this is ever called; on the outbound one there
|
|
83
|
+
* is nothing before this, so the check lives here.
|
|
84
|
+
*/
|
|
85
|
+
const askToAccept = entry => {
|
|
86
|
+
const cfg = settings();
|
|
87
|
+
if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
|
|
88
|
+
const had = pending.get(entry.fp);
|
|
89
|
+
// WHICH SWITCH ANSWERS depends on where the deck is. A request from the
|
|
90
|
+
// tailnet is answered by the Tailscale pair, and only for a machine on this
|
|
91
|
+
// person's own Tailscale account; anything else on the tailnet waits for a
|
|
92
|
+
// press whatever either switch says. See routeOf.
|
|
93
|
+
const route = routeTo(entry.addr);
|
|
94
|
+
const via = route ? "tailscale" : "lan";
|
|
95
|
+
const own = !!route?.own;
|
|
96
|
+
pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
|
|
97
|
+
if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && (via === "lan" || own)) {
|
|
98
|
+
engineNow()?.accept(entry.fp, { byHand: false });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!had) onChange?.();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A deck the beacon heard that nobody here has accepted: a row somebody
|
|
105
|
+
* can accept, and — when the ask switch for its route is on — asked. */
|
|
106
|
+
const heardStranger = entry => {
|
|
107
|
+
const cfg = settings();
|
|
108
|
+
const had = strangers.get(entry.fp);
|
|
109
|
+
// KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
|
|
110
|
+
// a fresh key — a second deck sharing one config directory does, by
|
|
111
|
+
// design — used to leave its old key in this map for a day, and every
|
|
112
|
+
// one of them drew a row offering to pair with the same machine.
|
|
113
|
+
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
114
|
+
const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
|
|
115
|
+
strangers.set(entry.fp, { ...entry, own });
|
|
116
|
+
// ASK IT, which is what the `ask` verb on its row does and nothing
|
|
117
|
+
// more: the address goes on the dial list and the next round sends a
|
|
118
|
+
// request that somebody over there still has to answer. A beacon
|
|
119
|
+
// carries a fingerprint and no key, so nothing is pinned here — see
|
|
120
|
+
// accept, which is deliberate about the difference.
|
|
121
|
+
//
|
|
122
|
+
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
123
|
+
// would be thirty seconds of asking; and never one this deck's owner
|
|
124
|
+
// already turned away.
|
|
125
|
+
// Never `byHand`: a beacon is a shout from an address nobody here
|
|
126
|
+
// named, and the row it leaves may ask rather than pin.
|
|
127
|
+
// Over the tailnet only a machine on this person's own account is
|
|
128
|
+
// asked unprompted; any other is a row for somebody to decide on.
|
|
129
|
+
const mayAsk = asksOn(cfg, entry.via) && (entry.via !== "tailscale" || own);
|
|
130
|
+
if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { engineNow().accept(entry.fp, { byHand: false }); return; }
|
|
131
|
+
// Only a deck that is new to us is news. A beacon every thirty
|
|
132
|
+
// seconds from one already on the list is not a reason to redraw.
|
|
133
|
+
if (!had) onChange?.();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a settings write does to the decks already waiting, given the
|
|
138
|
+
* settings before it — the engine's apply calls this with them, after the
|
|
139
|
+
* write is in force.
|
|
140
|
+
*/
|
|
141
|
+
const switched = was => {
|
|
142
|
+
// A request made before invite-only was enabled must not survive the
|
|
143
|
+
// switch and become an automatic approval when automatic mode returns.
|
|
144
|
+
// A fresh handshake after that switch may request pairing again.
|
|
145
|
+
if (was.pairingMode !== "invite" && settings().pairingMode === "invite" && pending.size) {
|
|
146
|
+
pending.clear();
|
|
147
|
+
onChange?.();
|
|
148
|
+
}
|
|
149
|
+
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
150
|
+
// this on with two rows sitting in the panel means those two as much as
|
|
151
|
+
// the next one, and leaving them queued behind a setting called
|
|
152
|
+
// "automatic" is the switch not doing what it says.
|
|
153
|
+
//
|
|
154
|
+
// PER ROUTE, because each pair of switches answers for its own: turning
|
|
155
|
+
// the local one on does not answer a tailnet request, and turning the
|
|
156
|
+
// Tailscale one on answers only the owner's own machines.
|
|
157
|
+
const turnedOn = (f, via) => !f(was, via) && f(settings(), via);
|
|
158
|
+
const mayAnswer = p => (p.via === "tailscale" ? turnedOn(saysYesOn, "tailscale") && p.own : turnedOn(saysYesOn, "lan"));
|
|
159
|
+
for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) engineNow().accept(fp, { byHand: false });
|
|
160
|
+
// The same for the other direction: switching `ask` on with four machines
|
|
161
|
+
// already listed asks those four.
|
|
162
|
+
const mayAsk = p => (p.via === "tailscale" ? turnedOn(asksOn, "tailscale") && p.own : turnedOn(asksOn, "lan"));
|
|
163
|
+
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) engineNow().accept(fp, { byHand: false });
|
|
164
|
+
// OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
|
|
165
|
+
// and the engine's tailnet poll stops the reads. Decks already paired stay
|
|
166
|
+
// paired.
|
|
167
|
+
if (was.tailscale && !settings().tailscale) {
|
|
168
|
+
for (const [fp, p] of [...strangers]) if (p.via === "tailscale") strangers.delete(fp);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** The heard decks status() offers to pair with — see its `strangers`. */
|
|
173
|
+
const strangerRows = () => {
|
|
174
|
+
// A deck that was told no is not somebody to offer pairing with. It
|
|
175
|
+
// has its own row, with the one control that undoes the decision.
|
|
176
|
+
const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
|
|
177
|
+
// pairable() collapses the rest by machine — see hostId. A computer
|
|
178
|
+
// that has run the deck a few times holds a key per run, and every
|
|
179
|
+
// one of them was a row of its own on everybody else's panel.
|
|
180
|
+
const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
|
|
181
|
+
return shown.map(p => ({
|
|
182
|
+
fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more,
|
|
183
|
+
via: p.via ?? "lan", own: !!p.own,
|
|
184
|
+
}));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
askToAccept,
|
|
189
|
+
heardStranger,
|
|
190
|
+
switched,
|
|
191
|
+
/** Whether somebody here told this deck no — asked before a request is
|
|
192
|
+
* drawn, on both paths. */
|
|
193
|
+
isDeclined: fp => declined.has(fp),
|
|
194
|
+
/** The row a deck is waiting on: its request when it asked, its beacon
|
|
195
|
+
* when it was only heard, or null. */
|
|
196
|
+
seen: fp => (pending.get(fp) ?? null) ?? (strangers.get(fp) ?? null),
|
|
197
|
+
/** It was answered: off both lists. */
|
|
198
|
+
drop(fp) {
|
|
199
|
+
pending.delete(fp);
|
|
200
|
+
strangers.delete(fp);
|
|
201
|
+
},
|
|
202
|
+
/** A heard deck was asked: its address is on the dial list now, and its
|
|
203
|
+
* row comes back as a request once the round reaches it. */
|
|
204
|
+
dropHeard(fp) {
|
|
205
|
+
strangers.delete(fp);
|
|
206
|
+
},
|
|
207
|
+
/** Say no, and stop being asked. The deck comes off the first list it is
|
|
208
|
+
* on — its request if it asked, its heard row if it was only heard — and
|
|
209
|
+
* onto the declined list, which is what stops the asking: a dial from it
|
|
210
|
+
* is refused as "declined" (lan-socket.mjs), askToAccept draws no request
|
|
211
|
+
* for it, and nothing here asks it. Not a block, though: `allow` undoes it.
|
|
212
|
+
*
|
|
213
|
+
* A deck that asked AND was heard keeps its heard row. That is fine: the
|
|
214
|
+
* next beacon would write that row back whatever this did, because
|
|
215
|
+
* heardStranger records a declined deck like any other and only never
|
|
216
|
+
* asks it, and what offers or asks from the heard list — strangerRows,
|
|
217
|
+
* heardStranger and switched — checks the declined list first. */
|
|
218
|
+
dismiss(fp) {
|
|
219
|
+
// Whatever the row said, kept — the panel draws a declined deck by name
|
|
220
|
+
// and address, and after the delete below there is nowhere else to read
|
|
221
|
+
// them from.
|
|
222
|
+
const was = pending.get(fp) ?? strangers.get(fp) ?? null;
|
|
223
|
+
const had = pending.delete(fp) || strangers.delete(fp);
|
|
224
|
+
if (had) {
|
|
225
|
+
declined.set(fp, {
|
|
226
|
+
fp,
|
|
227
|
+
name: was?.name ?? fp,
|
|
228
|
+
addr: was?.addr ?? "",
|
|
229
|
+
port: was?.port ?? 0,
|
|
230
|
+
at: now(),
|
|
231
|
+
});
|
|
232
|
+
onChange?.();
|
|
233
|
+
}
|
|
234
|
+
return had;
|
|
235
|
+
},
|
|
236
|
+
/** Change your mind. The name comes off the declined list and the next time
|
|
237
|
+
* that deck dials, it is a request again — which it will, on its own, so
|
|
238
|
+
* there is nothing else to press. */
|
|
239
|
+
allow(fp) {
|
|
240
|
+
const had = declined.delete(fp);
|
|
241
|
+
if (had) onChange?.();
|
|
242
|
+
return had;
|
|
243
|
+
},
|
|
244
|
+
/** The rows status() draws for each list. */
|
|
245
|
+
pendingRows: () => [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at, via: p.via ?? "lan", own: !!p.own })),
|
|
246
|
+
strangerRows,
|
|
247
|
+
declinedRows: () => [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// What the last round did with each deck, and when the whole round last
|
|
2
|
+
// finished: lifted out of createEngine in lan-engine.mjs with the two
|
|
3
|
+
// variables only these read and write, and the one rule read off them — is
|
|
4
|
+
// anybody on the other end still deciding. The round in the engine writes a
|
|
5
|
+
// record per deck it asked; the panel's rows, a deck's own `check now` and the
|
|
6
|
+
// clock that picks the next round's gap (lan-round-timer.mjs) read them.
|
|
7
|
+
|
|
8
|
+
/** `now` is the engine's clock. */
|
|
9
|
+
export function createRoundRecord({ now }) {
|
|
10
|
+
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
11
|
+
* recent only, because "what happened" is a question about now. */
|
|
12
|
+
const lastRound = new Map();
|
|
13
|
+
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
14
|
+
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
15
|
+
* pressed it wants to know it happened, and a reader who did not wants to
|
|
16
|
+
* know the list is not a photograph of an hour ago. */
|
|
17
|
+
let roundAt = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Is anybody on the other end still deciding?
|
|
21
|
+
*
|
|
22
|
+
* The exact sentence lan-call.mjs gives for "a real deck, not yet
|
|
23
|
+
* accepted", which is the one state where dialling again in a few seconds
|
|
24
|
+
* does something a minute later would not.
|
|
25
|
+
*/
|
|
26
|
+
const waitingOnSomebody = () =>
|
|
27
|
+
[...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
/** What the last ask of a row did, or undefined. Keyed by the ROW the
|
|
31
|
+
* round dialled, which for a typed address is the placeholder built from
|
|
32
|
+
* it rather than the fingerprint that answered there. */
|
|
33
|
+
of: fp => lastRound.get(fp),
|
|
34
|
+
/** An ask of that row finished, well or not, and this is what it did. The
|
|
35
|
+
* record is kept as it is handed over, so the same object before and
|
|
36
|
+
* after a wait means nothing asked that row in between — see roundOne. */
|
|
37
|
+
keep(fp, record) { lastRound.set(fp, record); },
|
|
38
|
+
/** Nothing of that row is drawn any more: a dial-back taken away. */
|
|
39
|
+
drop(fp) { lastRound.delete(fp); },
|
|
40
|
+
/** A whole round finished — see roundAt. */
|
|
41
|
+
finished() { roundAt = now(); },
|
|
42
|
+
checkedAt: () => roundAt,
|
|
43
|
+
waitingOnSomebody,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// When the next sync round runs, lifted out of createEngine in lan-engine.mjs
|
|
2
|
+
// with the one variable only it used — the timer — and the two gaps it picks
|
|
3
|
+
// between. What a round does is the engine's, and the line every round waits
|
|
4
|
+
// in is lan-turns.mjs's; this is only the clock that asks for the next one.
|
|
5
|
+
|
|
6
|
+
/** How often a deck asks its peers what they have. A minute is far more often
|
|
7
|
+
* than a login dies, and it is what makes the panel's list feel live rather
|
|
8
|
+
* than something that updates when you press a button. */
|
|
9
|
+
export const SYNC_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How long to wait before dialling again while somebody is deciding.
|
|
13
|
+
*
|
|
14
|
+
* A minute is right for the steady state — two decks whose logins all work have
|
|
15
|
+
* nothing to say to each other — and it is far too long for the one moment
|
|
16
|
+
* anybody is watching: the seconds after somebody presses accept on the other
|
|
17
|
+
* machine. Reported as "it should work by itself", from a panel that had been
|
|
18
|
+
* correct for up to fifty-nine more seconds than the person in front of it.
|
|
19
|
+
*
|
|
20
|
+
* So the loop tightens while a request is outstanding and relaxes the moment it
|
|
21
|
+
* is answered — either way. A refusal is an answer, and a deck that said no is
|
|
22
|
+
* not asked every eight seconds.
|
|
23
|
+
*/
|
|
24
|
+
export const ASKING_MS = 8_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `round` runs one whole round, the engine's own, which waits its turn in the
|
|
28
|
+
* line. `waiting` says whether a deck this one dialled is still deciding
|
|
29
|
+
* whether to accept it — the one state where asking again in a few seconds
|
|
30
|
+
* does something a minute later would not.
|
|
31
|
+
*/
|
|
32
|
+
export function createRoundTimer({ round, waiting }) {
|
|
33
|
+
/** The next round, while one is scheduled. */
|
|
34
|
+
let timer = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run a round a whole SYNC_MS after the listener comes up, and another each
|
|
38
|
+
* time the last one finishes, until `ended` says the start that began the
|
|
39
|
+
* loop is over — stopped, restarted or switched off.
|
|
40
|
+
*/
|
|
41
|
+
const start = ended => {
|
|
42
|
+
// A self-scheduling loop rather than one interval, because the gap
|
|
43
|
+
// between rounds is not one number: see ASKING_MS.
|
|
44
|
+
const tick = async () => {
|
|
45
|
+
try { await round(); } catch { /* a round reports itself, per peer */ }
|
|
46
|
+
if (ended()) return;
|
|
47
|
+
timer = setTimeout(() => { void tick(); }, waiting() ? ASKING_MS : SYNC_MS);
|
|
48
|
+
timer.unref?.();
|
|
49
|
+
};
|
|
50
|
+
timer = setTimeout(() => { void tick(); }, SYNC_MS);
|
|
51
|
+
timer.unref?.();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Call off the round scheduled next. One already running finishes, and
|
|
55
|
+
* schedules nothing after it once its start has ended. */
|
|
56
|
+
const stop = () => {
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
timer = null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { start, stop };
|
|
62
|
+
}
|