@decentnetwork/beagle 0.1.86 → 0.1.88
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/dist/cli.js +6 -0
- package/dist/desktop/app.js +9 -5
- package/dist/embedded-host.js +9 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ipc.d.ts +34 -1
- package/dist/ipc.js +58 -2
- package/dist/lan-handoff.d.ts +14 -2
- package/dist/lan-handoff.js +76 -4
- package/dist/peer-host.js +3 -3
- package/dist/server.js +38 -46
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -145,6 +145,12 @@ async function main() {
|
|
|
145
145
|
makeEmbedded: openEmbedded,
|
|
146
146
|
makeDaemon: () => makeDaemonHost(decentlanCarrierDir(args.configDir)),
|
|
147
147
|
onChange: (state) => console.log(`[LAN] backend is now: ${state}`),
|
|
148
|
+
// `--backend` has to reach the watcher, or it only ever pinned the FIRST
|
|
149
|
+
// choice: this wrapper re-detected on a 1500ms timer and overrode it a
|
|
150
|
+
// second and a half later. That is why `--backend embedded` did not
|
|
151
|
+
// protect Beagle Desktop on Windows, and why `--backend daemon` on 245
|
|
152
|
+
// still fell back and took the identity lock.
|
|
153
|
+
force: args.backend,
|
|
148
154
|
});
|
|
149
155
|
// If this machine also runs a dora, surface its allocation table.
|
|
150
156
|
const doraCandidates = args.doraDir
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.87";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -561,6 +561,9 @@ window.__DK_UI_VERSION="0.1.85";
|
|
|
561
561
|
const ta = a.lastTs || 0, tb = b.lastTs || 0;
|
|
562
562
|
if (ta !== tb)
|
|
563
563
|
return tb - ta;
|
|
564
|
+
const aa = a.acceptedAt || 0, ba = b.acceptedAt || 0;
|
|
565
|
+
if (aa !== ba)
|
|
566
|
+
return ba - aa;
|
|
564
567
|
return String(a.alias || a.userId || "").localeCompare(String(b.alias || b.userId || ""));
|
|
565
568
|
});
|
|
566
569
|
const online = peers.filter((p) => p.online).length;
|
|
@@ -4317,8 +4320,9 @@ ${peer.address}`
|
|
|
4317
4320
|
Object.assign(window, { useTweaks });
|
|
4318
4321
|
function ownershipFix(info) {
|
|
4319
4322
|
const r = String(info && info.selfUpdateBlocked || "");
|
|
4320
|
-
const at = r.indexOf("sudo
|
|
4321
|
-
|
|
4323
|
+
const at = r.indexOf("sudo npm i -g");
|
|
4324
|
+
const end = at >= 0 ? r.indexOf(".", at) : -1;
|
|
4325
|
+
return at >= 0 ? r.slice(at, end > at ? end : void 0) : "sudo npm i -g @decentnetwork/beagle@latest";
|
|
4322
4326
|
}
|
|
4323
4327
|
function dkParseDeepLinkAddress() {
|
|
4324
4328
|
try {
|
|
@@ -4860,7 +4864,7 @@ ${peer.address}`
|
|
|
4860
4864
|
lineHeight: 1.6,
|
|
4861
4865
|
borderLeft: "2px solid var(--danger)",
|
|
4862
4866
|
paddingLeft: 10
|
|
4863
|
-
} }, T.updNoPerm || "
|
|
4867
|
+
} }, T.updNoPerm || "This install lives under a system-owned prefix, so updating it is a privileged operation and this button cannot do it. Run it in a terminal:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: {
|
|
4864
4868
|
flex: 1,
|
|
4865
4869
|
minWidth: 0,
|
|
4866
4870
|
fontFamily: "var(--mono)",
|
|
@@ -4872,7 +4876,7 @@ ${peer.address}`
|
|
|
4872
4876
|
padding: "8px 10px",
|
|
4873
4877
|
userSelect: "all",
|
|
4874
4878
|
wordBreak: "break-all"
|
|
4875
|
-
} }, ownershipFix(info)), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy(ownershipFix(info)) })), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6, fontSize: 11.5, color: "var(--faint)" } }, T.updNoPermAlt || "
|
|
4879
|
+
} }, ownershipFix(info)), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy(ownershipFix(info)) })), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6, fontSize: 11.5, color: "var(--faint)" } }, T.updNoPermAlt || "Do not widen permissions there to make this button work \u2014 a group-writable bin directory lets any admin user replace programs that root later runs. For one-click updates, move npm out of the system prefix instead: `npm config set prefix ~/.npm-global`, then put `~/.npm-global/bin` first on your PATH.")), phase === "idle" && appBehind && info.mode === "dev" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updDev || "Running from a source checkout \u2014 update with:", /* @__PURE__ */ React.createElement("br", null), /* @__PURE__ */ React.createElement("code", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)" } }, "git pull && npm run build")), phase === "idle" && appBehind && info.mode === "unknown" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updUnknown || "Update now runs the global install. If that is not how this was installed, run:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/beagle@latest"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/beagle@latest") }))), phase === "idle" && appBehind && info.mode === "npx" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updNpx || "Running via npx \u2014 restarting picks up the latest version automatically."), daemonPeerBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updPeerDaemon || "The peer version belongs to the running agentnet daemon, not to this app. Update it with:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), lanBehind && phase === "idle" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)", lineHeight: 1.5 } }, T.updLanNote || "agentnet (lan): one click reinstalls it and asks the running daemon to restart itself. Manual equivalent:", /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 6 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, "npm i -g @decentnetwork/lan@latest && agentnet restart"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy("npm i -g @decentnetwork/lan@latest && agentnet restart") }))), phase === "lan-done" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--online)" } }, T.updLanDone || "lan updated \u2014 daemon restarted with the new version \u2713"), phase === "lan-norestart" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--warn, #f59e0b)" } }, T.updLanNoRestart || "lan updated, but the daemon was not running (or refused) \u2014 the new version applies on its next start."), phase === "running" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--dim)" } }, T.updBusy || "Updating\u2026 (about a minute)"), phase === "done" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--online)" } }, T.updDone || "Updated \u2014 restart to apply."), phase === "restarting" && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--dim)" } }, T.updRestarting || "Restarting\u2026 this page reloads when it\u2019s back. (If beagle was started by hand in a terminal, run it again.)"), phase === "error" && (() => {
|
|
4876
4880
|
const m = /`([^`]+)`/.exec(err || "");
|
|
4877
4881
|
return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--danger)", wordBreak: "break-word" } }, m ? err.replace("`" + m[1] + "`", "") : err), m && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("code", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--text)", background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 8, padding: "8px 10px", userSelect: "all", wordBreak: "break-all" } }, m[1]), /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "copy", title: T.copy || "Copy", onClick: () => dkCopy(m[1]) })), m && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)" } }, T.updManualHint || "Run it in a terminal, then click Restart to apply."));
|
|
4878
4882
|
})(), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 } }, (phase === "idle" || phase === "error" || phase === "lan-done" || phase === "lan-norestart") && btn(phase === "idle" ? T.updLater || "Later" : T.receivedClose || "Close", onClose), phase === "idle" && lanBehind && btn(T.updLanNow || "Update lan", doLanUpdate, !appBehind), phase === "idle" && appBehind && (info.mode === "global" || info.mode === "unknown") && btn(T.updNow || "Update now", doUpdate, true), phase === "idle" && appBehind && info.mode === "npx" && btn(T.updRestart || "Restart", doRestart, true), phase === "error" && btn(T.updRestart || "Restart", doRestart, true), phase === "done" && btn(T.updRestart || "Restart", doRestart, true))));
|
package/dist/embedded-host.js
CHANGED
|
@@ -103,12 +103,18 @@ export class EmbeddedHost {
|
|
|
103
103
|
// Infinity - Infinity = NaN, and the tie-break would only still work
|
|
104
104
|
// by accident of NaN being falsy.
|
|
105
105
|
last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
|
|
106
|
+
// Among friends you have never messaged, NEWEST FRIENDSHIP first.
|
|
107
|
+
// Alphabetical put someone you accepted ten seconds ago wherever their
|
|
108
|
+
// name happened to land — usually far down a 50-name list, so accepting
|
|
109
|
+
// a request looked like nothing had happened. The one friend you are
|
|
110
|
+
// most likely to open is the one you just added.
|
|
111
|
+
-(f.acceptedAt ?? 0),
|
|
106
112
|
(f.name || f.carrierId).toLowerCase(),
|
|
107
113
|
];
|
|
108
114
|
return [...friends].sort((a, b) => {
|
|
109
|
-
const [ap, at, an] = rank(a);
|
|
110
|
-
const [bp, bt, bn] = rank(b);
|
|
111
|
-
return ap - bp || at - bt || an.localeCompare(bn);
|
|
115
|
+
const [ap, at, aa, an] = rank(a);
|
|
116
|
+
const [bp, bt, ba, bn] = rank(b);
|
|
117
|
+
return ap - bp || at - bt || aa - ba || an.localeCompare(bn);
|
|
112
118
|
});
|
|
113
119
|
}
|
|
114
120
|
get downloadsDir() {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { startBeagleServer, type BeagleServerOptions } from "./server.js";
|
|
2
2
|
export { openPeerHost, defaultConfigDir, decentlanCarrierDir, type PeerHost, type BackendKind } from "./peer-host.js";
|
|
3
|
-
export { ipcCall, ipcSocketPath,
|
|
3
|
+
export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist, type IpcRequest, type IpcResponse, type IpcOp } from "./ipc.js";
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// server and the backend abstraction are exported so it can be embedded.
|
|
3
3
|
export { startBeagleServer } from "./server.js";
|
|
4
4
|
export { openPeerHost, defaultConfigDir, decentlanCarrierDir } from "./peer-host.js";
|
|
5
|
-
export { ipcCall, ipcSocketPath,
|
|
5
|
+
export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist } from "./ipc.js";
|
package/dist/ipc.d.ts
CHANGED
|
@@ -43,6 +43,39 @@ export declare function ipcSocketPath(dataDir: string, platform?: NodeJS.Platfor
|
|
|
43
43
|
/** fs.existsSync is meaningful for Unix-domain sockets but always false for
|
|
44
44
|
* Windows named pipes, so on Windows we connect and let net report. */
|
|
45
45
|
export declare function ipcSocketSupportsFsExistenceCheck(platform?: NodeJS.Platform): boolean;
|
|
46
|
-
|
|
46
|
+
/** How long the liveness probe waits for a connect before calling it dead.
|
|
47
|
+
* Local IPC: a listening socket accepts in microseconds, so this is a
|
|
48
|
+
* generous ceiling that still keeps the 1500ms watch tick honest. */
|
|
49
|
+
export declare const DAEMON_PROBE_TIMEOUT_MS = 400;
|
|
50
|
+
/**
|
|
51
|
+
* Is a decentlan daemon actually LISTENING for this identity?
|
|
52
|
+
*
|
|
53
|
+
* The only trustworthy answer is a connection. The previous check asked the
|
|
54
|
+
* filesystem, which cannot answer on either platform:
|
|
55
|
+
*
|
|
56
|
+
* - Windows named pipes never appear to `existsSync`, so the old code
|
|
57
|
+
* returned a hard-coded `true` — "can't tell; try it". Every beagle on
|
|
58
|
+
* Windows therefore believed in a daemon that was not there, handed its
|
|
59
|
+
* identity to a pipe nobody served, and failed every peer op with
|
|
60
|
+
* `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
|
|
61
|
+
* add-friend, renaming and .beagles.eth registration were all broken.
|
|
62
|
+
* - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
|
|
63
|
+
* firmly as a live one, so the same promotion happened there too, only
|
|
64
|
+
* rarer.
|
|
65
|
+
*
|
|
66
|
+
* Connecting costs a round trip to a local socket and removes both. It is the
|
|
67
|
+
* same code on every platform, which is the real win: no branch that only one
|
|
68
|
+
* of us can test.
|
|
69
|
+
*/
|
|
70
|
+
export declare function probeDaemon(dataDir: string, timeoutMs?: number): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Sync, best-effort: could a daemon socket be there?
|
|
73
|
+
*
|
|
74
|
+
* Only for callers that genuinely cannot await. It answers `false` when it
|
|
75
|
+
* cannot tell — the opposite of the old default, and the whole point. A `true`
|
|
76
|
+
* from this is still only a hint; confirm with {@link probeDaemon} before
|
|
77
|
+
* handing an identity to anything.
|
|
78
|
+
*/
|
|
79
|
+
export declare function daemonSocketMayExist(dataDir: string): boolean;
|
|
47
80
|
/** One request, one newline-delimited JSON response. */
|
|
48
81
|
export declare function ipcCall(dataDir: string, req: IpcRequest, timeoutMs?: number): Promise<IpcResponse>;
|
package/dist/ipc.js
CHANGED
|
@@ -27,9 +27,65 @@ export function ipcSocketPath(dataDir, platform = process.platform) {
|
|
|
27
27
|
export function ipcSocketSupportsFsExistenceCheck(platform = process.platform) {
|
|
28
28
|
return platform !== "win32";
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
/** How long the liveness probe waits for a connect before calling it dead.
|
|
31
|
+
* Local IPC: a listening socket accepts in microseconds, so this is a
|
|
32
|
+
* generous ceiling that still keeps the 1500ms watch tick honest. */
|
|
33
|
+
export const DAEMON_PROBE_TIMEOUT_MS = 400;
|
|
34
|
+
/**
|
|
35
|
+
* Is a decentlan daemon actually LISTENING for this identity?
|
|
36
|
+
*
|
|
37
|
+
* The only trustworthy answer is a connection. The previous check asked the
|
|
38
|
+
* filesystem, which cannot answer on either platform:
|
|
39
|
+
*
|
|
40
|
+
* - Windows named pipes never appear to `existsSync`, so the old code
|
|
41
|
+
* returned a hard-coded `true` — "can't tell; try it". Every beagle on
|
|
42
|
+
* Windows therefore believed in a daemon that was not there, handed its
|
|
43
|
+
* identity to a pipe nobody served, and failed every peer op with
|
|
44
|
+
* `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
|
|
45
|
+
* add-friend, renaming and .beagles.eth registration were all broken.
|
|
46
|
+
* - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
|
|
47
|
+
* firmly as a live one, so the same promotion happened there too, only
|
|
48
|
+
* rarer.
|
|
49
|
+
*
|
|
50
|
+
* Connecting costs a round trip to a local socket and removes both. It is the
|
|
51
|
+
* same code on every platform, which is the real win: no branch that only one
|
|
52
|
+
* of us can test.
|
|
53
|
+
*/
|
|
54
|
+
export function probeDaemon(dataDir, timeoutMs = DAEMON_PROBE_TIMEOUT_MS) {
|
|
55
|
+
const sockPath = ipcSocketPath(dataDir);
|
|
56
|
+
// A cheap negative where the filesystem CAN answer. Never a positive: an
|
|
57
|
+
// existing socket file is not a listening daemon, which was the second bug.
|
|
58
|
+
if (ipcSocketSupportsFsExistenceCheck() && !existsSync(sockPath)) {
|
|
59
|
+
return Promise.resolve(false);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const sock = createConnection(sockPath);
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (alive) => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
sock.destroy();
|
|
70
|
+
resolve(alive);
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
sock.on("connect", () => finish(true));
|
|
75
|
+
sock.on("error", () => finish(false));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sync, best-effort: could a daemon socket be there?
|
|
80
|
+
*
|
|
81
|
+
* Only for callers that genuinely cannot await. It answers `false` when it
|
|
82
|
+
* cannot tell — the opposite of the old default, and the whole point. A `true`
|
|
83
|
+
* from this is still only a hint; confirm with {@link probeDaemon} before
|
|
84
|
+
* handing an identity to anything.
|
|
85
|
+
*/
|
|
86
|
+
export function daemonSocketMayExist(dataDir) {
|
|
31
87
|
if (!ipcSocketSupportsFsExistenceCheck())
|
|
32
|
-
return
|
|
88
|
+
return false;
|
|
33
89
|
return existsSync(ipcSocketPath(dataDir));
|
|
34
90
|
}
|
|
35
91
|
/** One request, one newline-delimited JSON response. */
|
package/dist/lan-handoff.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type IpcRequest, type IpcResponse } from "./ipc.js";
|
|
2
2
|
import { Logger } from "./logger.js";
|
|
3
|
-
import type { PeerHost } from "./peer-host.js";
|
|
4
|
-
export type HandoffState = "embedded" | "releasing" | "daemon";
|
|
3
|
+
import type { BackendKind, PeerHost } from "./peer-host.js";
|
|
4
|
+
export type HandoffState = "embedded" | "releasing" | "daemon" | "waiting";
|
|
5
|
+
/** How long a vanished daemon is treated as "restarting" before auto mode
|
|
6
|
+
* gives up on it and takes the identity back. `agentnet restart` and a
|
|
7
|
+
* launchd/systemd kickstart are both back well inside this. */
|
|
8
|
+
export declare const DAEMON_GRACE_MS: number;
|
|
5
9
|
export interface SwitchableHostOptions {
|
|
6
10
|
dataDir: string;
|
|
7
11
|
/** Build a fresh embedded host. Called on first start and on every fallback,
|
|
@@ -11,6 +15,14 @@ export interface SwitchableHostOptions {
|
|
|
11
15
|
makeDaemon: () => PeerHost;
|
|
12
16
|
onChange?: (state: HandoffState, host: PeerHost | null) => void;
|
|
13
17
|
log?: Logger;
|
|
18
|
+
/** The backend the user pinned with `--backend`, if any. A forced backend is
|
|
19
|
+
* never switched away from automatically — in EITHER direction. Auto mode
|
|
20
|
+
* (undefined) keeps the full state machine. */
|
|
21
|
+
force?: BackendKind;
|
|
22
|
+
/** Grace period before auto mode falls back from a vanished daemon. */
|
|
23
|
+
graceMs?: number;
|
|
24
|
+
/** Liveness probe, injectable for tests. Defaults to a real connect. */
|
|
25
|
+
probe?: (dataDir: string) => Promise<boolean>;
|
|
14
26
|
}
|
|
15
27
|
/**
|
|
16
28
|
* A PeerHost whose backend can change under it.
|
package/dist/lan-handoff.js
CHANGED
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
// `releasing` is the honest name for the middle state: our peer is down and the
|
|
14
14
|
// daemon is not up yet, so messaging is offline and the UI must say so. Hiding
|
|
15
15
|
// that gap would make dropped messages look like a network fault.
|
|
16
|
-
import {
|
|
16
|
+
import { probeDaemon } from "./ipc.js";
|
|
17
17
|
import { Logger } from "./logger.js";
|
|
18
18
|
/** How often we look for the daemon socket appearing or vanishing. */
|
|
19
19
|
const WATCH_INTERVAL_MS = 1500;
|
|
20
20
|
/** How long we stay in `releasing` before giving up and coming back. */
|
|
21
21
|
const RELEASE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
/** How long a vanished daemon is treated as "restarting" before auto mode
|
|
23
|
+
* gives up on it and takes the identity back. `agentnet restart` and a
|
|
24
|
+
* launchd/systemd kickstart are both back well inside this. */
|
|
25
|
+
export const DAEMON_GRACE_MS = 60 * 1000;
|
|
22
26
|
/**
|
|
23
27
|
* A PeerHost whose backend can change under it.
|
|
24
28
|
*
|
|
@@ -34,11 +38,16 @@ export class SwitchablePeerHost {
|
|
|
34
38
|
#timer = null;
|
|
35
39
|
#releaseDeadline = 0;
|
|
36
40
|
#busy = false;
|
|
41
|
+
/** When the daemon was first seen missing, 0 while it is present. Drives the
|
|
42
|
+
* grace period; reset the moment it answers again. */
|
|
43
|
+
#daemonMissingSince = 0;
|
|
44
|
+
#probe;
|
|
37
45
|
constructor(initial, opts) {
|
|
38
46
|
this.#inner = initial;
|
|
39
47
|
this.#state = initial.kind === "daemon" ? "daemon" : "embedded";
|
|
40
48
|
this.#opts = opts;
|
|
41
49
|
this.#log = opts.log ?? new Logger({ prefix: "LAN" });
|
|
50
|
+
this.#probe = opts.probe ?? ((dir) => probeDaemon(dir));
|
|
42
51
|
this.#watch();
|
|
43
52
|
}
|
|
44
53
|
get kind() {
|
|
@@ -62,7 +71,9 @@ export class SwitchablePeerHost {
|
|
|
62
71
|
// freezing, which reads as a bug rather than as "you have a step to do".
|
|
63
72
|
return {
|
|
64
73
|
ok: false,
|
|
65
|
-
error:
|
|
74
|
+
error: this.#state === "waiting"
|
|
75
|
+
? "The decentlan daemon is restarting. Beagle is holding this identity for it and will re-attach automatically."
|
|
76
|
+
: "Beagle released its peer and is waiting for the decentlan daemon to start. Messaging resumes when it does.",
|
|
66
77
|
};
|
|
67
78
|
}
|
|
68
79
|
return this.#inner.call(req);
|
|
@@ -122,7 +133,39 @@ export class SwitchablePeerHost {
|
|
|
122
133
|
async #tick() {
|
|
123
134
|
if (this.#busy)
|
|
124
135
|
return;
|
|
125
|
-
const daemonUp =
|
|
136
|
+
const daemonUp = await this.#probe(this.#opts.dataDir);
|
|
137
|
+
if (daemonUp)
|
|
138
|
+
this.#daemonMissingSince = 0;
|
|
139
|
+
else if (this.#daemonMissingSince === 0)
|
|
140
|
+
this.#daemonMissingSince = Date.now();
|
|
141
|
+
// A tick can await; make sure nothing started while we were probing.
|
|
142
|
+
if (this.#busy)
|
|
143
|
+
return;
|
|
144
|
+
// `--backend daemon` means "this box's identity belongs to the daemon".
|
|
145
|
+
// Honour that at RUNTIME too, not just at startup. Falling back here is
|
|
146
|
+
// what deadlocked 245 twice: the embedded peer took <dataDir>/daemon.pid,
|
|
147
|
+
// launchd then could not restart the daemon (it refuses when a live
|
|
148
|
+
// process holds the identity), and the "way back" could never fire because
|
|
149
|
+
// it waits for a daemon that our own lock is keeping down. `waiting` is
|
|
150
|
+
// the honest state — no peer, no lock, ops rejected with a reason — and it
|
|
151
|
+
// costs a minute of messaging instead of needing a human to kill beagle
|
|
152
|
+
// and rm the pidfile.
|
|
153
|
+
if (this.#opts.force === "daemon") {
|
|
154
|
+
if (this.#state === "daemon" && !daemonUp) {
|
|
155
|
+
this.#log.info("the daemon went away — waiting for it to come back (forced daemon backend)");
|
|
156
|
+
const old = this.#inner;
|
|
157
|
+
this.#inner = null;
|
|
158
|
+
this.#setState("waiting");
|
|
159
|
+
await old?.stop();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (this.#state === "waiting" && daemonUp) {
|
|
163
|
+
this.#log.info("the daemon is back — re-attaching");
|
|
164
|
+
this.#inner = this.#opts.makeDaemon();
|
|
165
|
+
this.#setState("daemon");
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
126
169
|
if (this.#state === "releasing") {
|
|
127
170
|
if (daemonUp) {
|
|
128
171
|
this.#busy = true;
|
|
@@ -143,7 +186,18 @@ export class SwitchablePeerHost {
|
|
|
143
186
|
// The daemon went away (stopped, uninstalled, crashed). Take the identity
|
|
144
187
|
// back so messaging keeps working — a user who stops the LAN service should
|
|
145
188
|
// not silently lose chat.
|
|
189
|
+
//
|
|
190
|
+
// But WAIT first. A daemon that was here a second ago is far more often
|
|
191
|
+
// restarting than gone: `agentnet restart` is the documented upgrade step,
|
|
192
|
+
// and a supervisor kickstart looks identical. Grabbing the identity lock in
|
|
193
|
+
// that gap is what stops the daemon coming back at all, so the fast path
|
|
194
|
+
// was the deadlock. One minute of the daemon's own downtime is cheap; the
|
|
195
|
+
// deadlock cost 20 minutes and a human.
|
|
146
196
|
if (this.#state === "daemon" && !daemonUp) {
|
|
197
|
+
const downMs = Date.now() - this.#daemonMissingSince;
|
|
198
|
+
const graceMs = this.#opts.graceMs ?? DAEMON_GRACE_MS;
|
|
199
|
+
if (downMs < graceMs)
|
|
200
|
+
return; // probably a restart — say nothing, just wait
|
|
147
201
|
await this.#backToEmbedded("the daemon is no longer running");
|
|
148
202
|
return;
|
|
149
203
|
}
|
|
@@ -155,7 +209,12 @@ export class SwitchablePeerHost {
|
|
|
155
209
|
// was running the whole time, invisible to it. The version panel then
|
|
156
210
|
// reported the APP's peer as the daemon's, so an update that had actually
|
|
157
211
|
// landed looked like it had not.
|
|
158
|
-
|
|
212
|
+
//
|
|
213
|
+
// Not when the user pinned `--backend embedded`, though. That flag used to
|
|
214
|
+
// stop at startup: cli.ts wrapped the forced host in this watcher, which
|
|
215
|
+
// promoted it right back. Beagle Desktop passes `--backend embedded` and
|
|
216
|
+
// was demoted within 1.5s anyway — on Windows to a pipe nobody served.
|
|
217
|
+
if (this.#state === "embedded" && daemonUp && this.#opts.force !== "embedded") {
|
|
159
218
|
await this.#backToDaemon();
|
|
160
219
|
}
|
|
161
220
|
}
|
|
@@ -189,6 +248,19 @@ export class SwitchablePeerHost {
|
|
|
189
248
|
return;
|
|
190
249
|
this.#busy = true;
|
|
191
250
|
try {
|
|
251
|
+
// Last look before we commit. makeEmbedded() takes <dataDir>/daemon.pid,
|
|
252
|
+
// and that write is the point of no return: from then on the daemon
|
|
253
|
+
// cannot start, so it cannot prove us wrong. Rather than thread a probe
|
|
254
|
+
// into acquireIdentityLock — which would have to be sync, and a sync
|
|
255
|
+
// check is exactly what could not answer on Windows — close the window
|
|
256
|
+
// here, where the decision is actually made and awaiting is free.
|
|
257
|
+
if (await this.#probe(this.#opts.dataDir)) {
|
|
258
|
+
this.#log.info("the daemon answered after all — staying off the identity lock");
|
|
259
|
+
this.#daemonMissingSince = 0;
|
|
260
|
+
this.#inner = this.#opts.makeDaemon();
|
|
261
|
+
this.#setState("daemon");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
192
264
|
this.#log.info(`falling back to the embedded peer: ${why}`);
|
|
193
265
|
this.#inner = null;
|
|
194
266
|
this.#inner = await this.#opts.makeEmbedded();
|
package/dist/peer-host.js
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { resolve } from "node:path";
|
|
22
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
23
|
import yaml from "js-yaml";
|
|
24
|
-
import {
|
|
24
|
+
import { ipcCall, ipcSocketPath, probeDaemon } from "./ipc.js";
|
|
25
25
|
/** Where decentlan keeps its Carrier identity and daemon socket. Beagle reuses
|
|
26
26
|
* this identity when it exists so a user's friends carry over — the whole
|
|
27
27
|
* point of the split is that it is the same node, not a new one. */
|
|
@@ -73,7 +73,7 @@ export function makeDaemonHost(dataDir) {
|
|
|
73
73
|
*/
|
|
74
74
|
export async function openPeerHost(opts) {
|
|
75
75
|
const dataDir = decentlanCarrierDir(opts.configDir);
|
|
76
|
-
if (opts.force !== "embedded" &&
|
|
76
|
+
if (opts.force !== "embedded" && (await probeDaemon(dataDir))) {
|
|
77
77
|
return { host: new DaemonHost(dataDir), why: `decentlan daemon at ${dataDir}` };
|
|
78
78
|
}
|
|
79
79
|
if (opts.force === "daemon") {
|
|
@@ -91,7 +91,7 @@ export async function openPeerHost(opts) {
|
|
|
91
91
|
const waitMs = Math.max(0, opts.waitForDaemonMs ?? 60_000);
|
|
92
92
|
const deadline = Date.now() + waitMs;
|
|
93
93
|
let waited = false;
|
|
94
|
-
while (!
|
|
94
|
+
while (!(await probeDaemon(dataDir))) {
|
|
95
95
|
if (Date.now() >= deadline) {
|
|
96
96
|
throw new Error(`--backend daemon was requested but no decentlan daemon appeared within ${Math.round(waitMs / 1000)}s ` +
|
|
97
97
|
`(looked for ${ipcSocketPath(dataDir)}).`);
|
package/dist/server.js
CHANGED
|
@@ -350,11 +350,14 @@ async function npmGlobalInstall(pkg) {
|
|
|
350
350
|
return { ok: false, error: `npm not found — update manually: npm i -g ${pkg}@latest` };
|
|
351
351
|
}
|
|
352
352
|
/**
|
|
353
|
-
* Whether this process could
|
|
353
|
+
* Whether this process could perform a global update at all.
|
|
354
354
|
*
|
|
355
|
-
* Checked up front so the
|
|
356
|
-
*
|
|
357
|
-
*
|
|
355
|
+
* Checked up front so the card can say what is actually true instead of
|
|
356
|
+
* offering a button that fails. A real access() on each directory npm has to
|
|
357
|
+
* write, not a guess from the uid: the package dir it rewrites, the bin dir it
|
|
358
|
+
* re-links through, and the cache it downloads into. Checking only the first
|
|
359
|
+
* reported "you can update" on a machine that then failed on the bin links,
|
|
360
|
+
* and then again on the cache.
|
|
358
361
|
*/
|
|
359
362
|
async function globalNpmWritable() {
|
|
360
363
|
try {
|
|
@@ -366,15 +369,8 @@ async function globalNpmWritable() {
|
|
|
366
369
|
return { ok: true }; // nothing installed there yet
|
|
367
370
|
const { access } = await import("node:fs/promises");
|
|
368
371
|
const { constants } = await import("node:fs");
|
|
369
|
-
// BOTH, and both as directories. npm rewrites the package directory and
|
|
370
|
-
// re-links the binaries, and either one being unwritable stops the update.
|
|
371
|
-
// Checking only the scope reported "you can update" on a machine that then
|
|
372
|
-
// failed on the bin links.
|
|
373
372
|
await access(scope, constants.W_OK);
|
|
374
373
|
await access(join(prefix, "bin"), constants.W_OK);
|
|
375
|
-
// And the cache. A root-owned ~/.npm fails the install just as hard, and
|
|
376
|
-
// reporting "you can update" because the other two were fine is the same
|
|
377
|
-
// false promise this check exists to stop making.
|
|
378
374
|
const cache = await npmCacheDir();
|
|
379
375
|
if (cache && existsSync(cache))
|
|
380
376
|
await access(cache, constants.W_OK);
|
|
@@ -385,49 +381,40 @@ async function globalNpmWritable() {
|
|
|
385
381
|
}
|
|
386
382
|
}
|
|
387
383
|
/**
|
|
388
|
-
*
|
|
384
|
+
* What to tell someone whose global npm directory this process cannot write.
|
|
389
385
|
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
386
|
+
* NOT a permission change. Two earlier versions of this were.
|
|
387
|
+
*
|
|
388
|
+
* The first said `sudo npm i -g`, which works and leaves the package directory
|
|
389
|
+
* root-owned, so the next unprivileged update fails identically — the advice
|
|
390
|
+
* was what kept people in the loop.
|
|
391
|
+
*
|
|
392
|
+
* The second said `chgrp -R admin` plus `chmod -R g+w` on the bin directory.
|
|
393
|
+
* On the machine it was run on that made node, npm, docker and kubectl
|
|
394
|
+
* writable by every admin user, while the daemon runs as root out of that same
|
|
395
|
+
* /usr/local/bin/node — a root escalation traded for a convenience button. The
|
|
396
|
+
* user stopped it before it did any harm, and was right to.
|
|
397
|
+
*
|
|
398
|
+
* A root-owned prefix is not a misconfiguration to repair. It is the machine
|
|
399
|
+
* saying installs there are privileged. So: say the button cannot do it, give
|
|
400
|
+
* the command to run by hand, and name the one change that removes the problem
|
|
401
|
+
* without weakening anything — moving npm's prefix into $HOME.
|
|
395
402
|
*/
|
|
396
403
|
async function globalNpmOwnershipHint(pkg) {
|
|
397
404
|
const prefix = await npmGlobalPrefix();
|
|
398
405
|
if (!prefix) {
|
|
399
|
-
return `no write access to the global npm
|
|
406
|
+
return `no write access to the global npm directory — update in a terminal with \`sudo npm i -g ${pkg}@latest\`.`;
|
|
400
407
|
}
|
|
401
408
|
const scope = join(prefix, "lib", "node_modules", "@decentnetwork");
|
|
402
|
-
const bin = join(prefix, "bin");
|
|
403
|
-
// Both DIRECTORIES, not the files inside them. Replacing a package directory
|
|
404
|
-
// or a bin symlink is a rename, and rename needs write permission on the
|
|
405
|
-
// CONTAINING directory — naming the symlinks themselves does nothing when
|
|
406
|
-
// their parent is root-owned. An earlier version of this hint listed
|
|
407
|
-
// bin/agentnet and bin/beagle and left people exactly as stuck, one error
|
|
408
|
-
// further along: the scope chown succeeded and npm then failed on the bin
|
|
409
|
-
// links instead.
|
|
410
409
|
const cache = await npmCacheDir();
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// write access to every binary in a shared directory — a way to replace
|
|
420
|
-
// something another user, or root, will run. Creating or replacing a symlink
|
|
421
|
-
// needs write on the CONTAINING directory and nothing else, so: no -R, no
|
|
422
|
-
// group bit, no touching the files inside. Mode stays 755 and only this user
|
|
423
|
-
// gains write.
|
|
424
|
-
//
|
|
425
|
-
// The cleaner answer for anyone willing to change their PATH is not to touch
|
|
426
|
-
// /usr/local at all — `npm config set prefix ~/.npm-global` and put its bin
|
|
427
|
-
// first. That needs more than one line, so the card says it in prose.
|
|
428
|
-
return (`no write access to ${scope}. This is what a first install with sudo leaves behind, and ` +
|
|
429
|
-
`\`sudo npm i -g\` would rebuild it — run this ONCE and updates never need sudo again: ` +
|
|
430
|
-
`sudo chown -R $(id -u):$(id -g) ${scope}${cache ? ` ${cache}` : ""} && sudo chown $(id -u) ${bin}`);
|
|
410
|
+
const cacheNote = cache
|
|
411
|
+
? ` If npm also complains about its cache, that one IS yours to fix: sudo chown -R $(id -u):$(id -g) ${cache}.`
|
|
412
|
+
: "";
|
|
413
|
+
return (`${scope} belongs to root, so updates here are privileged and this button cannot perform them. ` +
|
|
414
|
+
`Update in a terminal: sudo npm i -g ${pkg}@latest.${cacheNote} ` +
|
|
415
|
+
`For one-click updates instead, move npm out of the system prefix — ` +
|
|
416
|
+
`\`npm config set prefix ~/.npm-global\`, then put ~/.npm-global/bin first on PATH. ` +
|
|
417
|
+
`Do not widen permissions under ${prefix} to make this button work.`);
|
|
431
418
|
}
|
|
432
419
|
/** `npm config get cache`, or undefined if npm cannot be reached. */
|
|
433
420
|
async function npmCacheDir() {
|
|
@@ -1988,6 +1975,11 @@ export function startBeagleServer(opts) {
|
|
|
1988
1975
|
// be ordered, which is why the sidebar never reordered on new
|
|
1989
1976
|
// activity.
|
|
1990
1977
|
lastTs: lm?.ts ?? 0,
|
|
1978
|
+
// When the friendship was established. The sidebar sorts by last
|
|
1979
|
+
// message and falls back to this, so a friend you just accepted —
|
|
1980
|
+
// who by definition has no messages — lands at the top of the
|
|
1981
|
+
// no-history group instead of alphabetically halfway down it.
|
|
1982
|
+
acceptedAt: f.lastSeen ?? 0,
|
|
1991
1983
|
wire: "163",
|
|
1992
1984
|
avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
|
|
1993
1985
|
// What they advertised over Carrier wins over the directory: it is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "npm run build -w @decentnetwork/beagle-ui && tsc -p tsconfig.json && chmod +x dist/cli.js && node scripts/build-ui.mjs",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "tsc -p tsconfig.test.json && node --test .test-build/test/*.test.js",
|
|
24
25
|
"clean": "rm -rf dist",
|
|
25
26
|
"start": "node dist/cli.js"
|
|
26
27
|
},
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"yargs": "^17.7.2"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
|
-
"@decentnetwork/beagle-ui": "^0.2.
|
|
38
|
+
"@decentnetwork/beagle-ui": "^0.2.7",
|
|
38
39
|
"@types/js-yaml": "^4.0.9",
|
|
39
40
|
"@types/node": "^20.11.0",
|
|
40
41
|
"@types/yargs": "^17.0.32",
|