@decentnetwork/lan 0.1.298 → 0.1.300
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.
|
@@ -14,10 +14,19 @@ export interface ChatMessage {
|
|
|
14
14
|
ts: number;
|
|
15
15
|
/** Stable per-message id (ts + per-process sequence) for UI keys / dedup. */
|
|
16
16
|
id: string;
|
|
17
|
-
/** Outgoing-text delivery state. "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
17
|
+
/** Outgoing-text delivery state. "sending" = the background delivery attempt
|
|
18
|
+
* is still running (chat-send returns before the network is consulted).
|
|
19
|
+
* "queued" = that attempt gave up, or the peer was offline when the user hit
|
|
20
|
+
* send; the daemon delivers it (in order) the moment the friend reconnects.
|
|
21
|
+
* Cleared once actually sent.
|
|
22
|
+
*
|
|
23
|
+
* The two states must stay distinct: chat-send used to store the message as
|
|
24
|
+
* "queued" while its own send was in flight, and the reconnect flush sends
|
|
25
|
+
* everything "queued". sendText brings the session up first when it has to,
|
|
26
|
+
* and the session coming up IS the reconnect event — so the flush sent the
|
|
27
|
+
* message, then chat-send sent it again, 10-20 ms apart. Every friend of a
|
|
28
|
+
* daemon saw those messages twice. */
|
|
29
|
+
status?: "sending" | "queued" | "sent" | "failed";
|
|
21
30
|
/** How this message traversed the network, for the UI to distinguish at a
|
|
22
31
|
* glance (different colors). "online" = live net_crypto session (direct/relay);
|
|
23
32
|
* "offline" = express store-and-forward (the friend or we were offline). Lets
|
|
@@ -52,11 +61,11 @@ export declare class MessageStore {
|
|
|
52
61
|
/** Append a message and schedule a flush. Returns the stored message.
|
|
53
62
|
* Pass `status: "queued"` for an outgoing text the peer wasn't online to
|
|
54
63
|
* receive — the daemon flushes it on reconnect. */
|
|
55
|
-
append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
|
|
64
|
+
append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "sending" | "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
|
|
56
65
|
/** Set (or, with undefined, clear) the delivery status on a text message.
|
|
57
66
|
* Used to flip a "queued" message to delivered once it's actually sent.
|
|
58
67
|
* No-op if the id isn't found or is a file entry. Returns true if patched. */
|
|
59
|
-
setStatus(peer: string, id: string, status?: "queued" | "sent" | "failed"): boolean;
|
|
68
|
+
setStatus(peer: string, id: string, status?: "sending" | "queued" | "sent" | "failed"): boolean;
|
|
60
69
|
/** Outgoing messages still awaiting delivery (peer was offline), oldest
|
|
61
70
|
* first — both queued text and queued file chips. The daemon drains this
|
|
62
71
|
* on a friend's reconnect. */
|
|
@@ -33,6 +33,11 @@ export class MessageStore {
|
|
|
33
33
|
let total = 0;
|
|
34
34
|
for (const [peer, msgs] of Object.entries(raw)) {
|
|
35
35
|
if (Array.isArray(msgs)) {
|
|
36
|
+
// A "sending" state cannot survive a restart — the attempt it
|
|
37
|
+
// described died with the process. Requeue so the flush owns it.
|
|
38
|
+
for (const m of msgs)
|
|
39
|
+
if (m.status === "sending")
|
|
40
|
+
m.status = "queued";
|
|
36
41
|
this.byPeer.set(peer, msgs);
|
|
37
42
|
total += msgs.length;
|
|
38
43
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -458,14 +458,23 @@ export class DaemonServer {
|
|
|
458
458
|
//
|
|
459
459
|
// Same rule the embedded host and the browser client already follow:
|
|
460
460
|
// a delivery failure is a STATE on the message, never its absence.
|
|
461
|
-
|
|
461
|
+
//
|
|
462
|
+
// Stored as "sending", NOT "queued", while our own attempt runs. The
|
|
463
|
+
// reconnect flush re-sends everything "queued", and sendText brings
|
|
464
|
+
// the session up itself when it has to — which fires the very
|
|
465
|
+
// friend-connection event that triggers the flush. Stored as "queued"
|
|
466
|
+
// the message went out twice, 10-20 ms apart (seen on every daemon
|
|
467
|
+
// sender in ~/.agentnet/messages.json). "queued" now means exactly
|
|
468
|
+
// one thing: nobody is sending this, the flush must.
|
|
469
|
+
const online = !!this.peerManager?.isFriendOnline(userid);
|
|
470
|
+
const msg = this.messageStore?.append(userid, "out", text, Date.now(), online ? "sending" : "queued");
|
|
462
471
|
this.friendMeta?.ensure(userid);
|
|
463
472
|
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
464
|
-
if (!msg || !
|
|
473
|
+
if (!msg || !online) {
|
|
465
474
|
this.logger.info(`Queued text for ${userid.slice(0, 8)} (delivers on reconnect)`);
|
|
466
475
|
return;
|
|
467
476
|
}
|
|
468
|
-
// Deliver in the background; the reconnect flush
|
|
477
|
+
// Deliver in the background; on failure hand it to the reconnect flush.
|
|
469
478
|
void (async () => {
|
|
470
479
|
try {
|
|
471
480
|
await this.peerManager.sendText(userid, text);
|
|
@@ -473,7 +482,8 @@ export class DaemonServer {
|
|
|
473
482
|
this.messageStore?.setStatus(userid, msg.id, undefined); // delivered
|
|
474
483
|
}
|
|
475
484
|
catch (e) {
|
|
476
|
-
this.logger.warn(`sendText to ${userid.slice(0, 8)} failed,
|
|
485
|
+
this.logger.warn(`sendText to ${userid.slice(0, 8)} failed, queued for reconnect: ${e.message}`);
|
|
486
|
+
this.messageStore?.setStatus(userid, msg.id, "queued");
|
|
477
487
|
}
|
|
478
488
|
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
479
489
|
})();
|
|
@@ -129,6 +129,11 @@ export class DoraIntegration {
|
|
|
129
129
|
// crashed the daemon on startup.
|
|
130
130
|
try {
|
|
131
131
|
let record;
|
|
132
|
+
// True when the preferred IP was refused and config says we keep it
|
|
133
|
+
// anyway (dora.keepConfiguredIp). We then run unregistered on the
|
|
134
|
+
// configured address, still read the roster, and let the bootstrap
|
|
135
|
+
// retry keep asking for the preferred IP until its owner accepts it.
|
|
136
|
+
let keptConfiguredIp = false;
|
|
132
137
|
try {
|
|
133
138
|
record = await this.tryRegister(myUserid, myAddress, this.opts.preferredIp);
|
|
134
139
|
}
|
|
@@ -154,41 +159,63 @@ export class DoraIntegration {
|
|
|
154
159
|
const retryWithoutPreferredIp = /held by|in use|already taken|already in use|out of range/i.test(msg);
|
|
155
160
|
if (!retryWithoutPreferredIp)
|
|
156
161
|
throw err;
|
|
157
|
-
this.
|
|
158
|
-
|
|
162
|
+
if (this.opts.config.keepConfiguredIp && this.opts.preferredIp) {
|
|
163
|
+
this.logger.warn(`Preferred IP ${this.opts.preferredIp} not accepted (${msg}) — keeping it (dora.keepConfiguredIp); ` +
|
|
164
|
+
`running unregistered on it and retrying the registration in the background`);
|
|
165
|
+
keptConfiguredIp = true;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
this.logger.warn(`Preferred IP ${this.opts.preferredIp} not available (${msg}) — requesting any free IP`);
|
|
169
|
+
record = await this.tryRegister(myUserid, myAddress);
|
|
170
|
+
}
|
|
159
171
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
172
|
+
if (record) {
|
|
173
|
+
this.allocatedIp = record.virtualIp;
|
|
174
|
+
this.registered = true;
|
|
175
|
+
this.logger.info(`Registered with dora: ${record.name} -> ${record.virtualIp}`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.allocatedIp = this.opts.preferredIp;
|
|
179
|
+
}
|
|
180
|
+
const selfIp = record?.virtualIp ?? this.opts.preferredIp ?? "";
|
|
181
|
+
const selfName = record?.name ?? this.opts.nodeName;
|
|
163
182
|
// Add self to the in-memory IPAM so the local DNS server can
|
|
164
183
|
// answer `<my-name>.<domain>` queries from this very host.
|
|
165
184
|
// mergeRosterIntoIpam skips own entry (sensible — peers don't
|
|
166
185
|
// need to "auto-friend themselves"), but the DNS resolver
|
|
167
186
|
// looks at the same IPAM, so without this entry `dig snoopy.
|
|
168
187
|
// decent` from snoopy itself NXDOMAINs.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// list() throws — we still have our own address allocated.
|
|
177
|
-
try {
|
|
178
|
-
const roster = await this.client.list();
|
|
179
|
-
this.mergeRosterIntoIpam(roster, myUserid);
|
|
188
|
+
if (selfIp) {
|
|
189
|
+
this.opts.ipam.assignPeer({
|
|
190
|
+
name: selfName,
|
|
191
|
+
carrierId: myUserid,
|
|
192
|
+
virtualIp: selfIp,
|
|
193
|
+
services: [],
|
|
194
|
+
});
|
|
180
195
|
}
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
// Pull the full roster and start the periodic refresh — once. The
|
|
197
|
+
// bootstrap retry re-enters this method every 30s while we are
|
|
198
|
+
// unregistered (keepConfiguredIp), and must not stack refresh timers.
|
|
199
|
+
if (!this.refreshTimer) {
|
|
200
|
+
// We don't fail the whole bootstrap if list() throws — we still
|
|
201
|
+
// have our own address.
|
|
202
|
+
try {
|
|
203
|
+
const roster = await this.client.list();
|
|
204
|
+
this.mergeRosterIntoIpam(roster, myUserid);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
this.logger.warn(`Initial roster fetch failed: ${err}`);
|
|
208
|
+
}
|
|
209
|
+
const interval = this.opts.config.refreshIntervalMs ?? 60_000;
|
|
210
|
+
this.refreshTimer = setInterval(() => {
|
|
211
|
+
this.refreshRoster(myUserid).catch((err) => {
|
|
212
|
+
this.logger.debug(`Roster refresh: ${err}`);
|
|
213
|
+
});
|
|
214
|
+
}, interval);
|
|
183
215
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
this.refreshRoster(myUserid).catch((err) => {
|
|
188
|
-
this.logger.debug(`Roster refresh: ${err}`);
|
|
189
|
-
});
|
|
190
|
-
}, interval);
|
|
191
|
-
return this.allocatedIp;
|
|
216
|
+
if (keptConfiguredIp)
|
|
217
|
+
this.scheduleBootstrapRetry();
|
|
218
|
+
return this.allocatedIp ?? "";
|
|
192
219
|
}
|
|
193
220
|
catch (err) {
|
|
194
221
|
if (err instanceof AllRegistriesUnavailableError) {
|
package/dist/types.d.ts
CHANGED
|
@@ -204,6 +204,24 @@ export interface DoraConfig {
|
|
|
204
204
|
* friended on every refresh; everything else is left alone.
|
|
205
205
|
*/
|
|
206
206
|
autoFriend?: "none" | "all" | string[];
|
|
207
|
+
/**
|
|
208
|
+
* Never let a registry move this node off `network.ip`.
|
|
209
|
+
*
|
|
210
|
+
* By default, when the preferred IP is rejected (collision, or "out of
|
|
211
|
+
* range" because the registry that owns its band did not answer in time),
|
|
212
|
+
* the daemon registers for ANY free IP and rebuilds the TUN on it. That is
|
|
213
|
+
* right for a throwaway node and catastrophic for one whose address is
|
|
214
|
+
* pinned elsewhere — an exit node listed by IP in every app, a server in
|
|
215
|
+
* someone's /etc/hosts. 2026-09-07: a routine restart moved the tokyo exit
|
|
216
|
+
* from 10.86.68.90 to 10.86.64.16 and gojipower from 10.86.166.16 to
|
|
217
|
+
* 10.86.192.10 this way.
|
|
218
|
+
*
|
|
219
|
+
* With `keepConfiguredIp: true` the daemon keeps `network.ip`, still
|
|
220
|
+
* pulls the roster (so `.decent` names resolve), and keeps retrying the
|
|
221
|
+
* preferred registration in the background until the owning registry
|
|
222
|
+
* accepts it. Set it on every node whose IP other things depend on.
|
|
223
|
+
*/
|
|
224
|
+
keepConfiguredIp?: boolean;
|
|
207
225
|
}
|
|
208
226
|
export interface DecentAgentNetConfig {
|
|
209
227
|
node: NodeConfig;
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.300";
|
|
2
2
|
const ICON_PATHS = {
|
|
3
3
|
// ---- tab bar (the four must feel like one set) ----
|
|
4
4
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.300",
|
|
4
4
|
"description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|