@decentnetwork/lan 0.1.284 → 0.1.286
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/carrier/offline-call.js +9 -3
- package/dist/daemon/friend-meta.d.ts +15 -0
- package/dist/daemon/friend-meta.js +12 -0
- package/dist/daemon/server.d.ts +11 -0
- package/dist/daemon/server.js +36 -1
- package/dist/dora/dora-integration.d.ts +9 -2
- package/dist/dora/dora-integration.js +24 -2
- package/dist/router/packet-router.js +3 -0
- package/dist/types.d.ts +8 -0
- package/dist/ui/desktop/app.js +1 -1
- package/package.json +1 -1
|
@@ -6,10 +6,16 @@ export const DEFAULT_OFFLINE_CONFIG = {
|
|
|
6
6
|
appName: "Beagle",
|
|
7
7
|
socketUrl: "https://io.beagle.chat",
|
|
8
8
|
socketUrls: ["https://io.beagle.chat"],
|
|
9
|
+
// Measured 2026-08-28: pushapi.beagle.chat returns 502 on every path (its
|
|
10
|
+
// vhost is down), www.callpass.cn is unreachable, and tokyo.fi.chat:3004 is
|
|
11
|
+
// the WRONG PORT — that service listens on 443. All three dead at once, so
|
|
12
|
+
// an offline call could not wake a phone, while the phone apps kept working
|
|
13
|
+
// because bgservers.json points them at tokyo.fi.chat on 443.
|
|
14
|
+
// Verified: POST https://tokyo.fi.chat/push-api/push-message with this
|
|
15
|
+
// client's exact query-string shape returns 200.
|
|
9
16
|
pushEndpoints: [
|
|
10
|
-
"https://
|
|
11
|
-
"https://
|
|
12
|
-
"https://tokyo.fi.chat:3004/push-api/push-message"
|
|
17
|
+
"https://tokyo.fi.chat/push-api/push-message",
|
|
18
|
+
"https://pushapi.beagle.chat/push-api/push-message"
|
|
13
19
|
]
|
|
14
20
|
};
|
|
15
21
|
/**
|
|
@@ -14,6 +14,18 @@ export interface FriendMeta {
|
|
|
14
14
|
pinned?: boolean;
|
|
15
15
|
/** ts of the newest message the user has seen — drives unread counts. */
|
|
16
16
|
lastReadTs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* When we first had PROOF this friendship is mutual — something arrived from
|
|
19
|
+
* them, or a send to them landed. Both need a two-way session, which needs
|
|
20
|
+
* them to have accepted.
|
|
21
|
+
*
|
|
22
|
+
* The SDK clears its own "requested" state on a dhtpk_update, which it reads
|
|
23
|
+
* as acceptance. It is not: that packet says the peer exists and is
|
|
24
|
+
* reachable, nothing about a human pressing Accept. Trusting it showed a
|
|
25
|
+
* peer as online before they had accepted, while every message to them
|
|
26
|
+
* queued — because there is no session to someone who has not added you.
|
|
27
|
+
*/
|
|
28
|
+
confirmedAt?: number;
|
|
17
29
|
addedAt: number;
|
|
18
30
|
}
|
|
19
31
|
export declare class FriendMetaStore {
|
|
@@ -29,6 +41,9 @@ export declare class FriendMetaStore {
|
|
|
29
41
|
ensure(userid: string): FriendMeta;
|
|
30
42
|
setAlias(userid: string, alias: string | undefined): void;
|
|
31
43
|
markRead(userid: string, ts?: number): void;
|
|
44
|
+
/** Record proof the friendship is mutual. Idempotent; first proof wins. */
|
|
45
|
+
markConfirmed(userid: string, ts?: number): boolean;
|
|
46
|
+
isConfirmed(userid: string): boolean;
|
|
32
47
|
setPinned(userid: string, pinned: boolean): void;
|
|
33
48
|
remove(userid: string): void;
|
|
34
49
|
private scheduleSave;
|
|
@@ -59,6 +59,18 @@ export class FriendMetaStore {
|
|
|
59
59
|
this.scheduleSave();
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** Record proof the friendship is mutual. Idempotent; first proof wins. */
|
|
63
|
+
markConfirmed(userid, ts = Date.now()) {
|
|
64
|
+
const m = this.ensure(userid);
|
|
65
|
+
if (m.confirmedAt)
|
|
66
|
+
return false;
|
|
67
|
+
m.confirmedAt = ts;
|
|
68
|
+
this.scheduleSave();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
isConfirmed(userid) {
|
|
72
|
+
return !!this.byUserid.get(userid)?.confirmedAt;
|
|
73
|
+
}
|
|
62
74
|
setPinned(userid, pinned) {
|
|
63
75
|
const m = this.ensure(userid);
|
|
64
76
|
m.pinned = pinned || undefined;
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -135,6 +135,17 @@ export declare class DaemonServer {
|
|
|
135
135
|
* text and queued files, oldest first. Called when a friend reconnects.
|
|
136
136
|
* Stops early if the link drops again, leaving the remainder queued for the
|
|
137
137
|
* next reconnect (true store-and-forward). Re-entrancy-guarded per peer. */
|
|
138
|
+
/**
|
|
139
|
+
* One-time backfill so upgrading does not re-open settled friendships.
|
|
140
|
+
*
|
|
141
|
+
* requestedAt stays on a friend record forever, so the new "unproven"
|
|
142
|
+
* rule would mark every friend we ever ASKED — i.e. most of them — as
|
|
143
|
+
* "requested" again until the next message. An inbound message is the same
|
|
144
|
+
* proof the live rule uses: they could only have sent it over a session that
|
|
145
|
+
* required them to accept us. So anyone we have already heard from is
|
|
146
|
+
* confirmed at startup, and only genuinely unaccepted requests stay pending.
|
|
147
|
+
*/
|
|
148
|
+
private backfillConfirmedFriends;
|
|
138
149
|
private flushOutbox;
|
|
139
150
|
/** Per-peer timestamp of the last self-heal friend-request re-send, so
|
|
140
151
|
* the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
|
package/dist/daemon/server.js
CHANGED
|
@@ -385,6 +385,7 @@ export class DaemonServer {
|
|
|
385
385
|
// On-disk chat + friend-metadata stores (survive restarts; back the UI).
|
|
386
386
|
this.messageStore = new MessageStore(resolve(this.configDir, "messages.json"));
|
|
387
387
|
this.friendMeta = new FriendMetaStore(resolve(this.configDir, "friends-meta.json"));
|
|
388
|
+
this.backfillConfirmedFriends();
|
|
388
389
|
this.outboxDir = resolve(this.configDir, "outbox");
|
|
389
390
|
this.reconcileOutboxOnStart();
|
|
390
391
|
// Start IPC as soon as the peer is up. The CLI uses it to drive
|
|
@@ -458,6 +459,7 @@ export class DaemonServer {
|
|
|
458
459
|
void (async () => {
|
|
459
460
|
try {
|
|
460
461
|
await this.peerManager.sendText(userid, text);
|
|
462
|
+
this.friendMeta?.markConfirmed(userid); // it landed: mutual
|
|
461
463
|
this.messageStore?.setStatus(userid, msg.id, undefined); // delivered
|
|
462
464
|
}
|
|
463
465
|
catch (e) {
|
|
@@ -491,6 +493,11 @@ export class DaemonServer {
|
|
|
491
493
|
// The build-default nickname is useless (every node sends it) —
|
|
492
494
|
// treat it as no-name so the UI falls back to alias/userid.
|
|
493
495
|
const realName = f.name && f.name !== "@decentnetwork/peer" ? f.name : undefined;
|
|
496
|
+
// A peer WE asked stays "requested" until proof arrives, whatever
|
|
497
|
+
// the SDK's own status says. Peers we did not ask (inbound
|
|
498
|
+
// requests we accepted) have no requestedAt, so they are
|
|
499
|
+
// unaffected — as is every friend already confirmed.
|
|
500
|
+
const unproven = !!f.requestedAt && !this.friendMeta?.isConfirmed(uid);
|
|
494
501
|
return {
|
|
495
502
|
userid: uid,
|
|
496
503
|
// Full Carrier address (pubkey+nospam+checksum) — the form a NEW
|
|
@@ -499,7 +506,7 @@ export class DaemonServer {
|
|
|
499
506
|
address: f.address,
|
|
500
507
|
alias: meta?.alias,
|
|
501
508
|
name: meta?.alias || realName || uid,
|
|
502
|
-
status: f.status,
|
|
509
|
+
status: unproven ? "requested" : f.status,
|
|
503
510
|
lastSeen: f.lastSeen,
|
|
504
511
|
pinned: meta?.pinned ?? false,
|
|
505
512
|
lastMessage: lastMsg ? { dir: lastMsg.dir, text: lastMsg.text, ts: lastMsg.ts } : undefined,
|
|
@@ -1146,6 +1153,7 @@ export class DaemonServer {
|
|
|
1146
1153
|
};
|
|
1147
1154
|
// Chat: log incoming Carrier text messages (packet 64) for the UI.
|
|
1148
1155
|
this.peerManager.on("message", (pubkey, text, via) => {
|
|
1156
|
+
this.friendMeta?.markConfirmed(pubkey); // they reached us: mutual
|
|
1149
1157
|
this.logChat(pubkey, "in", text, via);
|
|
1150
1158
|
});
|
|
1151
1159
|
// Presence: push to IPC subscribers so the TUI/UI reflects online/offline
|
|
@@ -1212,6 +1220,7 @@ export class DaemonServer {
|
|
|
1212
1220
|
}
|
|
1213
1221
|
});
|
|
1214
1222
|
this.peerManager.on("file-complete", (p) => {
|
|
1223
|
+
this.friendMeta?.markConfirmed(p.friendId);
|
|
1215
1224
|
if (p.sending || !p.data) {
|
|
1216
1225
|
if (p.sending) {
|
|
1217
1226
|
// The receiver has ACKed the whole file — now it's truly delivered.
|
|
@@ -1634,6 +1643,32 @@ export class DaemonServer {
|
|
|
1634
1643
|
* text and queued files, oldest first. Called when a friend reconnects.
|
|
1635
1644
|
* Stops early if the link drops again, leaving the remainder queued for the
|
|
1636
1645
|
* next reconnect (true store-and-forward). Re-entrancy-guarded per peer. */
|
|
1646
|
+
/**
|
|
1647
|
+
* One-time backfill so upgrading does not re-open settled friendships.
|
|
1648
|
+
*
|
|
1649
|
+
* requestedAt stays on a friend record forever, so the new "unproven"
|
|
1650
|
+
* rule would mark every friend we ever ASKED — i.e. most of them — as
|
|
1651
|
+
* "requested" again until the next message. An inbound message is the same
|
|
1652
|
+
* proof the live rule uses: they could only have sent it over a session that
|
|
1653
|
+
* required them to accept us. So anyone we have already heard from is
|
|
1654
|
+
* confirmed at startup, and only genuinely unaccepted requests stay pending.
|
|
1655
|
+
*/
|
|
1656
|
+
backfillConfirmedFriends() {
|
|
1657
|
+
if (!this.messageStore || !this.friendMeta)
|
|
1658
|
+
return;
|
|
1659
|
+
let marked = 0;
|
|
1660
|
+
const all = this.messageStore.history();
|
|
1661
|
+
for (const [peer, msgs] of Object.entries(all)) {
|
|
1662
|
+
if (this.friendMeta.isConfirmed(peer))
|
|
1663
|
+
continue;
|
|
1664
|
+
if (msgs.some((m) => m.dir === "in")) {
|
|
1665
|
+
this.friendMeta.markConfirmed(peer);
|
|
1666
|
+
marked++;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (marked)
|
|
1670
|
+
this.logger.info(`Confirmed ${marked} existing friend(s) from chat history`);
|
|
1671
|
+
}
|
|
1637
1672
|
async flushOutbox(userid) {
|
|
1638
1673
|
if (this.flushingOutbox.has(userid))
|
|
1639
1674
|
return;
|
|
@@ -141,8 +141,15 @@ export declare class DoraIntegration {
|
|
|
141
141
|
* ipam.yaml — that file remains operator-managed. Dora is treated as
|
|
142
142
|
* a live overlay that's refreshed on every restart.
|
|
143
143
|
*
|
|
144
|
-
* Conflict policy: dora wins
|
|
145
|
-
*
|
|
144
|
+
* Conflict policy: dora wins over ipam.yaml, but NOT over an address a real
|
|
145
|
+
* packet just proved. A peer that re-allocates shows up on the wire before
|
|
146
|
+
* dora's roster catches up, and letting the roster win there made the two
|
|
147
|
+
* writers fight: the router learned the live address from inbound traffic,
|
|
148
|
+
* the next refresh put the stale one back, and the mapping oscillated. From
|
|
149
|
+
* the outside that is a ping at ~60% loss — a few replies, then a run of
|
|
150
|
+
* timeouts, then replies again. Measured between mac-dev and air-wli
|
|
151
|
+
* 2026-08-30, with the flip visible in the log as alternating
|
|
152
|
+
* "IPAM updated from inbound" and "Assigned peer" lines for one peer.
|
|
146
153
|
*/
|
|
147
154
|
private mergeRosterIntoIpam;
|
|
148
155
|
/**
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
import { DoraClient, AllRegistriesUnavailableError } from "@decentnetwork/dora";
|
|
15
15
|
import { DEFAULT_DORAS } from "../config/loader.js";
|
|
16
16
|
import { Logger } from "../utils/logger.js";
|
|
17
|
+
/** How long an address confirmed by an inbound packet outranks the dora
|
|
18
|
+
* roster. Long enough to cover a roster that lags a re-allocation, short
|
|
19
|
+
* enough that a peer which genuinely moved is picked up from dora again. */
|
|
20
|
+
const LEARNED_IP_TRUST_MS = 10 * 60_000;
|
|
17
21
|
export class DoraIntegration {
|
|
18
22
|
opts;
|
|
19
23
|
client;
|
|
@@ -458,8 +462,15 @@ export class DoraIntegration {
|
|
|
458
462
|
* ipam.yaml — that file remains operator-managed. Dora is treated as
|
|
459
463
|
* a live overlay that's refreshed on every restart.
|
|
460
464
|
*
|
|
461
|
-
* Conflict policy: dora wins
|
|
462
|
-
*
|
|
465
|
+
* Conflict policy: dora wins over ipam.yaml, but NOT over an address a real
|
|
466
|
+
* packet just proved. A peer that re-allocates shows up on the wire before
|
|
467
|
+
* dora's roster catches up, and letting the roster win there made the two
|
|
468
|
+
* writers fight: the router learned the live address from inbound traffic,
|
|
469
|
+
* the next refresh put the stale one back, and the mapping oscillated. From
|
|
470
|
+
* the outside that is a ping at ~60% loss — a few replies, then a run of
|
|
471
|
+
* timeouts, then replies again. Measured between mac-dev and air-wli
|
|
472
|
+
* 2026-08-30, with the flip visible in the log as alternating
|
|
473
|
+
* "IPAM updated from inbound" and "Assigned peer" lines for one peer.
|
|
463
474
|
*/
|
|
464
475
|
mergeRosterIntoIpam(roster, myUserid) {
|
|
465
476
|
let added = 0;
|
|
@@ -471,6 +482,17 @@ export class DoraIntegration {
|
|
|
471
482
|
if (!existing) {
|
|
472
483
|
added += 1;
|
|
473
484
|
}
|
|
485
|
+
else if (existing.virtualIp !== entry.virtualIp &&
|
|
486
|
+
existing.learnedAt !== undefined &&
|
|
487
|
+
Date.now() - existing.learnedAt < LEARNED_IP_TRUST_MS) {
|
|
488
|
+
// Traffic from this peer confirmed a different address, recently.
|
|
489
|
+
// Believe the wire and leave the entry alone; the roster will agree
|
|
490
|
+
// once dora catches up with the re-allocation.
|
|
491
|
+
this.logger.info(`roster says ${entry.name} is ${entry.virtualIp}, but traffic proved ` +
|
|
492
|
+
`${existing.virtualIp} — keeping the learned address`);
|
|
493
|
+
this.maybeFriend(entry);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
474
496
|
else if (existing.virtualIp !== entry.virtualIp ||
|
|
475
497
|
existing.name !== entry.name) {
|
|
476
498
|
updated += 1;
|
|
@@ -244,6 +244,9 @@ export class PacketRouter extends EventEmitter {
|
|
|
244
244
|
carrierId: srcPubkey,
|
|
245
245
|
virtualIp: srcIp,
|
|
246
246
|
services: existing?.services ?? [],
|
|
247
|
+
// Stamped so a stale dora roster cannot overwrite what a real packet
|
|
248
|
+
// just proved. See IpamRecord.learnedAt.
|
|
249
|
+
learnedAt: Date.now(),
|
|
247
250
|
});
|
|
248
251
|
this.logger.info(existing
|
|
249
252
|
? `IPAM updated from inbound: ${name} ${existing.virtualIp} -> ${srcIp}`
|
package/dist/types.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export interface IpamRecord {
|
|
|
42
42
|
virtualIp: string;
|
|
43
43
|
services: Service[];
|
|
44
44
|
expiresAt?: number;
|
|
45
|
+
/** When this address was last confirmed by an inbound packet FROM this peer.
|
|
46
|
+
*
|
|
47
|
+
* A packet is proof of the address a peer is using right now; the dora
|
|
48
|
+
* roster is a cache that can lag a re-allocation. Without this the roster
|
|
49
|
+
* overwrote the learned address on every refresh and the router flipped
|
|
50
|
+
* between the two — visible as ~60% packet loss on a ping that otherwise
|
|
51
|
+
* worked. */
|
|
52
|
+
learnedAt?: number;
|
|
45
53
|
}
|
|
46
54
|
export interface IpamConfig {
|
|
47
55
|
namespace: string;
|
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.286";
|
|
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.286",
|
|
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",
|