@decentnetwork/lan 0.1.101 → 0.1.103

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.
Binary file
Binary file
Binary file
Binary file
@@ -435,10 +435,24 @@ function App({ client }) {
435
435
  }
436
436
  };
437
437
  void tick();
438
- const iv = setInterval(() => void tick(), 1400);
438
+ let iv = setInterval(() => {
439
+ if (alive) void tick();
440
+ }, 6e3);
441
+ const stop = client.subscribe(
442
+ () => {
443
+ if (alive) void tick();
444
+ },
445
+ () => {
446
+ clearInterval(iv);
447
+ iv = setInterval(() => {
448
+ if (alive) void tick();
449
+ }, 2500);
450
+ }
451
+ );
439
452
  return () => {
440
453
  alive = false;
441
454
  clearInterval(iv);
455
+ stop();
442
456
  };
443
457
  }, [client, selId]);
444
458
  React.useEffect(() => {
@@ -909,6 +923,64 @@ var DaemonClient = class _DaemonClient {
909
923
  });
910
924
  });
911
925
  }
926
+ /**
927
+ * Open a persistent subscription to daemon push events (chat / presence /
928
+ * friend-request). Calls `onEvent` for each event and auto-reconnects with
929
+ * backoff if the socket drops. Returns a stop() to tear it down.
930
+ *
931
+ * Degrades gracefully: an older daemon that doesn't know the `subscribe`
932
+ * op replies `{ok:false}` and closes — we surface that via `onUnsupported`
933
+ * so the caller can keep its polling fallback instead of reconnecting in a
934
+ * tight loop. A daemon that supports it acks with `{subscribed:true}` first.
935
+ */
936
+ subscribe(onEvent, onUnsupported) {
937
+ let stopped = false;
938
+ let sock;
939
+ let retry;
940
+ const connect = () => {
941
+ if (stopped) return;
942
+ let buf = "";
943
+ let acked = false;
944
+ sock = createConnection(this.sockPath);
945
+ sock.on("connect", () => sock.write(JSON.stringify({ op: "subscribe" }) + "\n"));
946
+ sock.on("data", (c) => {
947
+ buf += c.toString("utf-8");
948
+ let nl;
949
+ while ((nl = buf.indexOf("\n")) >= 0) {
950
+ const line = buf.slice(0, nl);
951
+ buf = buf.slice(nl + 1);
952
+ if (!line) continue;
953
+ try {
954
+ const msg = JSON.parse(line);
955
+ if (!acked) {
956
+ acked = true;
957
+ if (msg.ok === false) {
958
+ stopped = true;
959
+ sock?.end();
960
+ onUnsupported?.();
961
+ return;
962
+ }
963
+ continue;
964
+ }
965
+ if (msg.event) onEvent(msg.event);
966
+ } catch {
967
+ }
968
+ }
969
+ });
970
+ const reconnect = () => {
971
+ if (stopped) return;
972
+ retry = setTimeout(connect, 1500);
973
+ };
974
+ sock.on("error", reconnect);
975
+ sock.on("close", reconnect);
976
+ };
977
+ connect();
978
+ return () => {
979
+ stopped = true;
980
+ if (retry) clearTimeout(retry);
981
+ sock?.destroy();
982
+ };
983
+ }
912
984
  static data(r) {
913
985
  return r.ok && r.data ? r.data : {};
914
986
  }
@@ -56,6 +56,12 @@ export interface IpcHandlers {
56
56
  name?: string;
57
57
  description?: string;
58
58
  }) => Promise<void>;
59
+ /** Subscribe to daemon push events (chat / presence / friend-request). The
60
+ * IPC server keeps the connection open and calls `emit` for each event;
61
+ * the returned function unsubscribes (called on socket close). Optional —
62
+ * when absent, clients fall back to polling. Lets the TUI/UI react to new
63
+ * messages instantly instead of polling on a tight interval. */
64
+ subscribe?: (emit: (event: Record<string, unknown>) => void) => () => void;
59
65
  /** Offer a local file (by path) to a friend over toxcore file transfer. */
60
66
  fileSend: (userid: string, path: string) => Promise<Record<string, unknown>>;
61
67
  /** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
@@ -76,7 +82,7 @@ export interface IpcHandlers {
76
82
  selfRestart: () => Promise<Record<string, unknown>>;
77
83
  }
78
84
  export interface IpcRequest {
79
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "proxy-reload" | "self-restart";
85
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "proxy-reload" | "self-restart";
80
86
  address?: string;
81
87
  hello?: string;
82
88
  userid?: string;
@@ -109,6 +109,44 @@ export class IpcServer {
109
109
  reply({ ok: false, error: "malformed request (not JSON)" });
110
110
  return;
111
111
  }
112
+ // Streaming subscription: keep the socket OPEN and push newline-JSON
113
+ // events until the client disconnects. This is the one op that breaks
114
+ // the one-shot model — it lets the TUI/UI react to new messages and
115
+ // presence changes instantly instead of polling on a tight interval.
116
+ if (req.op === "subscribe") {
117
+ if (!this.handlers.subscribe) {
118
+ reply({ ok: false, error: "subscribe not supported" });
119
+ return;
120
+ }
121
+ // First line acks the subscription so the client knows push is live
122
+ // (and can distinguish an old daemon that rejects the op).
123
+ try {
124
+ sock.write(JSON.stringify({ ok: true, data: { subscribed: true } }) + "\n");
125
+ }
126
+ catch {
127
+ sock.end();
128
+ return;
129
+ }
130
+ const unsubscribe = this.handlers.subscribe((event) => {
131
+ try {
132
+ sock.write(JSON.stringify({ event }) + "\n");
133
+ }
134
+ catch {
135
+ // socket gone; cleanup happens via the close handler
136
+ }
137
+ });
138
+ const cleanup = () => {
139
+ try {
140
+ unsubscribe();
141
+ }
142
+ catch {
143
+ // best-effort
144
+ }
145
+ };
146
+ sock.on("close", cleanup);
147
+ sock.on("end", cleanup);
148
+ return;
149
+ }
112
150
  void this.dispatch(req)
113
151
  .then((data) => reply({ ok: true, ...(data ? { data } : {}) }))
114
152
  .catch((err) => reply({ ok: false, error: err instanceof Error ? err.message : String(err) }));
@@ -37,6 +37,10 @@ export declare class DaemonServer {
37
37
  * the `agentnet ui` chat window; not persisted across restarts. */
38
38
  private messageStore?;
39
39
  private friendMeta?;
40
+ /** Push-event fan-out for IPC subscribers (TUI/UI). Fires on inbound/outbound
41
+ * chat, presence changes, and friend-requests so clients can refresh on
42
+ * demand instead of polling on a tight interval. */
43
+ private readonly ipcEvents;
40
44
  private startedAt;
41
45
  private isRunning;
42
46
  private statusTimer?;
@@ -2,6 +2,7 @@
2
2
  * Daemon Server — wires together all components
3
3
  */
4
4
  import { resolve } from "path";
5
+ import { EventEmitter } from "events";
5
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
6
7
  import { PeerManager } from "../carrier/peer-manager.js";
7
8
  import { TunDevice } from "../tun/tun-device.js";
@@ -56,6 +57,10 @@ export class DaemonServer {
56
57
  * the `agentnet ui` chat window; not persisted across restarts. */
57
58
  messageStore;
58
59
  friendMeta;
60
+ /** Push-event fan-out for IPC subscribers (TUI/UI). Fires on inbound/outbound
61
+ * chat, presence changes, and friend-requests so clients can refresh on
62
+ * demand instead of polling on a tight interval. */
63
+ ipcEvents = new EventEmitter();
59
64
  startedAt = 0;
60
65
  isRunning = false;
61
66
  statusTimer;
@@ -204,6 +209,7 @@ export class DaemonServer {
204
209
  // Start IPC as soon as the peer is up. The CLI uses it to drive
205
210
  // operations that need the daemon's Carrier identity (e.g.
206
211
  // friend-request) without spawning a competing Peer instance.
212
+ this.ipcEvents.setMaxListeners(50);
207
213
  this.ipcServer = new IpcServer(ipcSocketPath(this.config.carrier.dataDir), {
208
214
  friendRequest: async (address, hello) => {
209
215
  await this.peerManager.sendFriendRequest(address, hello);
@@ -309,6 +315,11 @@ export class DaemonServer {
309
315
  chatMarkRead: async (userid, ts) => {
310
316
  this.friendMeta?.markRead(userid, ts);
311
317
  },
318
+ subscribe: (emit) => {
319
+ const onEvent = (event) => emit(event);
320
+ this.ipcEvents.on("event", onEvent);
321
+ return () => this.ipcEvents.off("event", onEvent);
322
+ },
312
323
  proxyReload: async () => {
313
324
  // Re-read the proxy allowlist from disk and push it into the
314
325
  // running proxy without a daemon restart (which would drop
@@ -555,6 +566,11 @@ export class DaemonServer {
555
566
  this.peerManager.on("message", (pubkey, text) => {
556
567
  this.logChat(pubkey, "in", text);
557
568
  });
569
+ // Presence: push to IPC subscribers so the TUI/UI reflects online/offline
570
+ // transitions immediately instead of on the next poll tick.
571
+ this.peerManager.on("friend-connection", (evt) => {
572
+ this.ipcEvents.emit("event", { type: "presence", userid: evt?.pubkey, status: evt?.status });
573
+ });
558
574
  // File transfer (toxcore-standard): auto-accept offers from friends and
559
575
  // save completed files under <configDir>/downloads/.
560
576
  this.peerManager.on("file-offer", (o) => {
@@ -580,6 +596,7 @@ export class DaemonServer {
580
596
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
581
597
  const who = `${req.name || "(unnamed)"} ${userid}`;
582
598
  const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
599
+ this.ipcEvents.emit("event", { type: "request", userid });
583
600
  if (autoAcceptFriends) {
584
601
  this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
585
602
  this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
@@ -802,6 +819,7 @@ export class DaemonServer {
802
819
  logChat(userid, dir, text) {
803
820
  this.messageStore?.append(userid, dir, text);
804
821
  this.friendMeta?.ensure(userid);
822
+ this.ipcEvents.emit("event", { type: "chat", userid, dir });
805
823
  }
806
824
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
807
825
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
@@ -942,11 +942,20 @@ function PeerRow({ peer, T, active, onClick }) {
942
942
  function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
943
943
  const [q, setQ] = React.useState("");
944
944
  const [addr, setAddr] = React.useState("");
945
+ const [addState, setAddState] = React.useState(null);
945
946
  const submitAddr = () => {
946
- if (addr.trim()) {
947
- onAdd(addr);
948
- setAddr("");
949
- }
947
+ const v = addr.trim();
948
+ if (!v || addState && addState.kind === "sending") return;
949
+ setAddState({ kind: "sending", msg: T.addSending });
950
+ Promise.resolve(onAdd(v)).then((r) => {
951
+ if (r && r.ok) {
952
+ setAddr("");
953
+ setAddState({ kind: "ok", msg: T.addSent });
954
+ } else {
955
+ setAddState({ kind: "err", msg: r && r.error ? `${T.addFailed}: ${r.error}` : T.addFailed });
956
+ }
957
+ setTimeout(() => setAddState((s) => s && s.kind !== "sending" ? null : s), 6e3);
958
+ });
950
959
  };
951
960
  const filtered = peers.filter((p) => {
952
961
  if (!q) return true;
@@ -956,7 +965,12 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
956
965
  const online = peers.filter((p) => p.online).length;
957
966
  return /* @__PURE__ */ React.createElement("div", { style: { width: 320, flexShrink: 0, borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { padding: "12px 12px 10px", borderBottom: "1px solid var(--line)", display: "flex", flexDirection: "column", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 7 } }, /* @__PURE__ */ React.createElement("input", { value: addr, onChange: (e) => setAddr(e.target.value), onKeyDown: (e) => {
958
967
  if (e.key === "Enter") submitAddr();
959
- }, placeholder: T.addPlaceholder, style: inputStyle }), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "userPlus", onClick: submitAddr }, T.add)), /* @__PURE__ */ React.createElement("div", { style: { position: "relative", display: "flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "search", size: 15, color: "var(--faint)", stroke: 2, style: { position: "absolute", left: 9 } }), /* @__PURE__ */ React.createElement("input", { value: q, onChange: (e) => setQ(e.target.value), placeholder: T.search, style: { ...inputStyle, paddingLeft: 30 } }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", padding: "10px 8px 16px" } }, /* @__PURE__ */ React.createElement(RequestsBlock, { T, requests, onAct }), /* @__PURE__ */ React.createElement(Section, { label: T.peers, count: `${online}/${peers.length}`, style: { margin: "6px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 2 } }, filtered.map((p) => /* @__PURE__ */ React.createElement(PeerRow, { key: p.id, peer: p, T, active: p.id === activeId, onClick: () => onSelect(p.id) })), !filtered.length && /* @__PURE__ */ React.createElement("div", { style: { padding: 16, textAlign: "center", fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "no matches"))));
968
+ }, placeholder: T.addPlaceholder, style: inputStyle }), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "userPlus", onClick: submitAddr }, T.add)), addState && /* @__PURE__ */ React.createElement("div", { style: {
969
+ fontFamily: "var(--mono)",
970
+ fontSize: 11.5,
971
+ lineHeight: 1.35,
972
+ color: addState.kind === "err" ? "var(--bad, #e5484d)" : addState.kind === "ok" ? "var(--good, #46a758)" : "var(--faint)"
973
+ } }, addState.msg), /* @__PURE__ */ React.createElement("div", { style: { position: "relative", display: "flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "search", size: 15, color: "var(--faint)", stroke: 2, style: { position: "absolute", left: 9 } }), /* @__PURE__ */ React.createElement("input", { value: q, onChange: (e) => setQ(e.target.value), placeholder: T.search, style: { ...inputStyle, paddingLeft: 30 } }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", padding: "10px 8px 16px" } }, /* @__PURE__ */ React.createElement(RequestsBlock, { T, requests, onAct }), /* @__PURE__ */ React.createElement(Section, { label: T.peers, count: `${online}/${peers.length}`, style: { margin: "6px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 2 } }, filtered.map((p) => /* @__PURE__ */ React.createElement(PeerRow, { key: p.id, peer: p, T, active: p.id === activeId, onClick: () => onSelect(p.id) })), !filtered.length && /* @__PURE__ */ React.createElement("div", { style: { padding: 16, textAlign: "center", fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "no matches"))));
960
974
  }
961
975
  const inputStyle = {
962
976
  flex: 1,
@@ -1205,6 +1219,9 @@ const STR = {
1205
1219
  addPlaceholder: "paste a friend's carrier address\u2026",
1206
1220
  add: "Add",
1207
1221
  search: "search peers / ip\u2026",
1222
+ addSending: "sending friend-request\u2026",
1223
+ addSent: "friend-request sent \u2014 they appear once they accept",
1224
+ addFailed: "could not send",
1208
1225
  requests: "Friend requests",
1209
1226
  accept: "accept",
1210
1227
  reject: "reject",
@@ -1277,6 +1294,9 @@ const STR = {
1277
1294
  addPlaceholder: "\u7C98\u8D34\u597D\u53CB\u7684 carrier \u5730\u5740\u2026",
1278
1295
  add: "\u6DFB\u52A0",
1279
1296
  search: "\u641C\u7D22\u597D\u53CB / IP\u2026",
1297
+ addSending: "\u6B63\u5728\u53D1\u9001\u597D\u53CB\u8BF7\u6C42\u2026",
1298
+ addSent: "\u597D\u53CB\u8BF7\u6C42\u5DF2\u53D1\u9001 \u2014 \u5BF9\u65B9\u63A5\u53D7\u540E\u4F1A\u51FA\u73B0\u5728\u5217\u8868",
1299
+ addFailed: "\u53D1\u9001\u5931\u8D25",
1280
1300
  requests: "\u597D\u53CB\u8BF7\u6C42",
1281
1301
  accept: "\u63A5\u53D7",
1282
1302
  reject: "\u62D2\u7EDD",
@@ -1397,7 +1417,11 @@ function DkApp() {
1397
1417
  if (peer.id === activeId) setActiveId(null);
1398
1418
  };
1399
1419
  const onAdd = (address) => {
1400
- if (address && address.trim()) dkApi.add(address.trim()).then(data.refresh);
1420
+ if (!address || !address.trim()) return Promise.resolve({ ok: false, error: "empty address" });
1421
+ return dkApi.add(address.trim()).then((r) => {
1422
+ data.refresh();
1423
+ return r;
1424
+ });
1401
1425
  };
1402
1426
  const onSend = (text) => {
1403
1427
  if (!activeId || !text || !text.trim()) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.101",
3
+ "version": "0.1.103",
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",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.6",
82
- "@decentnetwork/peer": "^0.1.44",
82
+ "@decentnetwork/peer": "^0.1.45",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
85
  "react": "^18.3.1",