@decentnetwork/lan 0.1.106 → 0.1.108

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
@@ -53,6 +53,10 @@ export declare class PeerManager extends EventEmitter {
53
53
  name?: string;
54
54
  description?: string;
55
55
  }): void;
56
+ /** Sign a message with this identity's private key (XEdDSA over the X25519
57
+ * Carrier key). The signature verifies against the userid. Used by "Sign in
58
+ * with Decent" to prove key possession to a website. Returns 64-byte sig. */
59
+ sign(message: Uint8Array): Uint8Array;
56
60
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
57
61
  sendFile(userid: string, data: Uint8Array, name: string): string | null;
58
62
  /** Accept an incoming file offer. */
@@ -106,6 +106,14 @@ export class PeerManager extends EventEmitter {
106
106
  this.peer.setUserInfo(info);
107
107
  this.logger.info(`Profile updated (name="${info.name ?? "(unchanged)"}")`);
108
108
  }
109
+ /** Sign a message with this identity's private key (XEdDSA over the X25519
110
+ * Carrier key). The signature verifies against the userid. Used by "Sign in
111
+ * with Decent" to prove key possession to a website. Returns 64-byte sig. */
112
+ sign(message) {
113
+ if (!this.peer)
114
+ throw new Error("Peer not created. Call create() first.");
115
+ return this.peer.sign(message);
116
+ }
109
117
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
110
118
  sendFile(userid, data, name) {
111
119
  if (!this.peer)
@@ -1176,6 +1176,7 @@ export async function cmdUi(args) {
1176
1176
  call: (req) => ipcCall(config, req),
1177
1177
  routesPath: resolve(dir, "routes.yaml"),
1178
1178
  doraRosterPath,
1179
+ downloadsDir: resolve(dir, "downloads"),
1179
1180
  meExtra: {
1180
1181
  lanVer: readJsonVer(join(moduleDir, "..", "..", "package.json")),
1181
1182
  peerVer,
@@ -56,6 +56,14 @@ export interface IpcHandlers {
56
56
  name?: string;
57
57
  description?: string;
58
58
  }) => Promise<void>;
59
+ /** Sign `text` (UTF-8) with this node's Carrier identity key and return the
60
+ * detached signature (hex) plus our userid. Backs "Sign in with Decent":
61
+ * the UI server's local-only /connect flow calls this on Approve. Reachable
62
+ * ONLY over this Unix socket — never exposed on a TCP interface. */
63
+ sign: (text: string) => Promise<{
64
+ sig: string;
65
+ userid: string;
66
+ }>;
59
67
  /** Subscribe to daemon push events (chat / presence / friend-request). The
60
68
  * IPC server keeps the connection open and calls `emit` for each event;
61
69
  * the returned function unsubscribes (called on socket close). Optional —
@@ -82,7 +90,7 @@ export interface IpcHandlers {
82
90
  selfRestart: () => Promise<Record<string, unknown>>;
83
91
  }
84
92
  export interface IpcRequest {
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";
93
+ 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" | "sign" | "proxy-reload" | "self-restart";
86
94
  address?: string;
87
95
  hello?: string;
88
96
  userid?: string;
@@ -221,6 +221,11 @@ export class IpcServer {
221
221
  await this.handlers.chatMarkRead(req.userid, req.ts);
222
222
  return;
223
223
  }
224
+ case "sign": {
225
+ if (typeof req.text !== "string")
226
+ throw new Error("text is required");
227
+ return await this.handlers.sign(req.text);
228
+ }
224
229
  case "proxy-reload":
225
230
  return await this.handlers.proxyReload();
226
231
  case "self-restart":
@@ -14,6 +14,13 @@ 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
+ /** Present when this entry is a file transfer rather than a text message.
18
+ * `name`/`size` describe the file; received files (dir:"in") are saved to
19
+ * <configDir>/downloads/<name> and downloadable via the UI. */
20
+ file?: {
21
+ name: string;
22
+ size: number;
23
+ };
17
24
  }
18
25
  export declare class MessageStore {
19
26
  private path;
@@ -26,6 +33,12 @@ export declare class MessageStore {
26
33
  private load;
27
34
  /** Append a message and schedule a flush. Returns the stored message. */
28
35
  append(peer: string, dir: "in" | "out", text: string, ts?: number): ChatMessage;
36
+ /** Append a file-transfer entry (shown as a download chip in the UI). */
37
+ appendFile(peer: string, dir: "in" | "out", file: {
38
+ name: string;
39
+ size: number;
40
+ }, ts?: number): ChatMessage;
41
+ private push;
29
42
  /**
30
43
  * Return history. With no peer, returns every peer's full thread (the legacy
31
44
  * chat-history shape). With a peer, supports pagination: `limit` newest
@@ -45,7 +45,13 @@ export class MessageStore {
45
45
  }
46
46
  /** Append a message and schedule a flush. Returns the stored message. */
47
47
  append(peer, dir, text, ts = Date.now()) {
48
- const msg = { dir, text, ts, id: `${ts}-${this.seq++}` };
48
+ return this.push(peer, { dir, text, ts, id: `${ts}-${this.seq++}` });
49
+ }
50
+ /** Append a file-transfer entry (shown as a download chip in the UI). */
51
+ appendFile(peer, dir, file, ts = Date.now()) {
52
+ return this.push(peer, { dir, text: "", ts, id: `${ts}-${this.seq++}`, file });
53
+ }
54
+ push(peer, msg) {
49
55
  let arr = this.byPeer.get(peer);
50
56
  if (!arr) {
51
57
  arr = [];
@@ -310,6 +310,10 @@ export class DaemonServer {
310
310
  if (!fileId)
311
311
  throw new Error("No free transfer slot (or friend unknown)");
312
312
  this.logger.info(`Offering file "${name}" (${data.length}B) to ${userid.slice(0, 8)}`);
313
+ // Record in chat history (as an outgoing file chip) and push to the UI.
314
+ this.messageStore?.appendFile(userid, "out", { name, size: data.length });
315
+ this.friendMeta?.ensure(userid);
316
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
313
317
  return { fileId, name, size: data.length };
314
318
  },
315
319
  chatMarkRead: async (userid, ts) => {
@@ -320,6 +324,13 @@ export class DaemonServer {
320
324
  this.ipcEvents.on("event", onEvent);
321
325
  return () => this.ipcEvents.off("event", onEvent);
322
326
  },
327
+ sign: async (text) => {
328
+ const sig = this.peerManager.sign(new TextEncoder().encode(text));
329
+ return {
330
+ sig: Buffer.from(sig).toString("hex"),
331
+ userid: this.peerManager.getIdentity().userid,
332
+ };
333
+ },
323
334
  proxyReload: async () => {
324
335
  // Re-read the proxy allowlist from disk and push it into the
325
336
  // running proxy without a daemon restart (which would drop
@@ -590,6 +601,10 @@ export class DaemonServer {
590
601
  const safe = (p.name || "file").replace(/[/\\]/g, "_");
591
602
  await fs.writeFile(resolve(dir, safe), Buffer.from(p.data));
592
603
  this.logger.info(`Saved file "${p.name}" (${p.size}B) from ${p.friendId.slice(0, 8)} → ${resolve(dir, safe)}`);
604
+ // Record in chat history (as a download chip) and push to the UI.
605
+ this.messageStore?.appendFile(p.friendId, "in", { name: safe, size: p.size });
606
+ this.friendMeta?.ensure(p.friendId);
607
+ this.ipcEvents.emit("event", { type: "chat", userid: p.friendId, dir: "in" });
593
608
  })().catch((e) => this.logger.warn(`Failed to save file: ${e.message}`));
594
609
  });
595
610
  this.peerManager.on("friend-request", (req) => {
@@ -131,6 +131,16 @@ function dkClock(ts) {
131
131
  const d = new Date(ts);
132
132
  return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
133
133
  }
134
+ function dkFileSize(n) {
135
+ if (!n || n < 0) return "0 B";
136
+ if (n < 1024) return n + " B";
137
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
138
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
139
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
140
+ }
141
+ function dkFileUrl(name) {
142
+ return "/api/file-download?name=" + encodeURIComponent(name);
143
+ }
134
144
  function dkDayLabel(ts) {
135
145
  if (!ts) return "Today";
136
146
  const d = new Date(ts), now = /* @__PURE__ */ new Date();
@@ -186,7 +196,11 @@ const dkApi = {
186
196
  remove: (userid) => dkPost("/api/friend-remove", { userid }),
187
197
  alias: (userid, alias) => dkPost("/api/friend-alias", { userid, alias }),
188
198
  markRead: (userid) => dkPost("/api/chat-mark-read", { userid }),
189
- setProfile: (name, description) => dkPost("/api/set-profile", { name, description })
199
+ setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
200
+ sendFile: (userid, file) => fetch(
201
+ "/api/file-send?userid=" + encodeURIComponent(userid) + "&name=" + encodeURIComponent(file.name),
202
+ { method: "POST", body: file }
203
+ ).then((r) => r.json()).catch((e) => ({ ok: false, error: String(e) }))
190
204
  };
191
205
  const DK_ME_FALLBACK = {
192
206
  name: "\u2026",
@@ -240,6 +254,7 @@ function useDaemonData() {
240
254
  from: m.dir === "out" ? "me" : "them",
241
255
  time: dkClock(m.ts),
242
256
  text: m.text,
257
+ file: m.file ? { name: m.file.name, size: dkFileSize(m.file.size), dir: m.dir } : void 0,
243
258
  status: m.dir === "out" ? "read" : void 0
244
259
  }));
245
260
  const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
@@ -987,16 +1002,27 @@ const inputStyle = {
987
1002
  };
988
1003
  function Msg({ m, peer, T }) {
989
1004
  const mine = m.from === "me";
990
- return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0" } }, !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? /* @__PURE__ */ React.createElement("div", { style: {
991
- display: "flex",
992
- alignItems: "center",
993
- gap: 10,
994
- padding: "10px 12px",
995
- borderRadius: 10,
996
- background: mine ? "var(--bub-me)" : "var(--bub-them)",
997
- border: "1px solid " + (mine ? "transparent" : "var(--line)"),
998
- minWidth: 200
999
- } }, /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.kind || "file", size: 18, stroke: 1.9 })), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, m.file.size)), /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: mine ? "rgba(255,255,255,0.85)" : "var(--dim)" })) : /* @__PURE__ */ React.createElement("div", { style: {
1005
+ return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0" } }, !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? React.createElement(
1006
+ mine ? "div" : "a",
1007
+ {
1008
+ ...mine ? {} : { href: dkFileUrl(m.file.name), download: m.file.name, title: "download" },
1009
+ style: {
1010
+ display: "flex",
1011
+ alignItems: "center",
1012
+ gap: 10,
1013
+ padding: "10px 12px",
1014
+ borderRadius: 10,
1015
+ background: mine ? "var(--bub-me)" : "var(--bub-them)",
1016
+ border: "1px solid " + (mine ? "transparent" : "var(--line)"),
1017
+ minWidth: 200,
1018
+ textDecoration: "none",
1019
+ cursor: mine ? "default" : "pointer"
1020
+ }
1021
+ },
1022
+ /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.kind || "file", size: 18, stroke: 1.9 })),
1023
+ /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, m.file.size, mine ? "" : " \xB7 download")),
1024
+ mine ? /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
1025
+ ) : /* @__PURE__ */ React.createElement("div", { style: {
1000
1026
  padding: "8px 12px",
1001
1027
  borderRadius: 12,
1002
1028
  borderBottomRightRadius: mine ? 4 : 12,
@@ -1011,14 +1037,17 @@ function Msg({ m, peer, T }) {
1011
1037
  wordBreak: "break-word"
1012
1038
  } }, m.text), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" }))));
1013
1039
  }
1014
- function Conversation({ T, peer, lang, thread: threadProp, onSend, onAlias, onRemove, onOpenNet }) {
1040
+ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
1015
1041
  const scrollRef = React.useRef(null);
1042
+ const fileRef = React.useRef(null);
1016
1043
  const thread = threadProp && threadProp.length ? threadProp : [{ day: "Today" }, { from: "them", time: "\u2014", text: lang === "zh" ? "\u6682\u65E0\u6D88\u606F\u8BB0\u5F55\uFF0C\u53D1\u4E2A\u6D88\u606F\u6253\u4E2A\u62DB\u547C\u5427\u3002" : "No messages yet. Say hi." }];
1017
1044
  React.useEffect(() => {
1018
1045
  if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
1019
1046
  }, [peer.id, threadProp]);
1020
1047
  const [menu, setMenu] = React.useState(false);
1021
1048
  const [draft, setDraft] = React.useState("");
1049
+ const [dragOver, setDragOver] = React.useState(false);
1050
+ const [sending, setSending] = React.useState(null);
1022
1051
  React.useEffect(() => {
1023
1052
  setDraft("");
1024
1053
  }, [peer.id]);
@@ -1028,27 +1057,68 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onAlias, onRe
1028
1057
  setDraft("");
1029
1058
  }
1030
1059
  };
1031
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1032
- setMenu(false);
1033
- onAlias(peer);
1034
- } }), /* @__PURE__ */ React.createElement("div", { style: { height: 1, background: "var(--line)", margin: "5px 4px" } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "trash", label: T.remove, danger: true, onClick: () => {
1035
- setMenu(false);
1036
- onRemove(peer);
1037
- } }))))), /* @__PURE__ */ React.createElement("div", { ref: scrollRef, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto" } }, thread.map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(Msg, { key: i, m, peer, T })))), /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, borderTop: "1px solid var(--line)", padding: "12px 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1038
- "input",
1060
+ const sendFiles = (files) => {
1061
+ if (!files || !files.length || !onSendFile) return;
1062
+ const file = files[0];
1063
+ setSending({ name: file.name });
1064
+ Promise.resolve(onSendFile(file)).then((r) => {
1065
+ if (r && r.ok === false) window.alert((lang === "zh" ? "\u53D1\u9001\u5931\u8D25: " : "Send failed: ") + (r.error || ""));
1066
+ }).finally(() => setSending(null));
1067
+ };
1068
+ const onDrop = (e) => {
1069
+ e.preventDefault();
1070
+ setDragOver(false);
1071
+ sendFiles(e.dataTransfer.files);
1072
+ };
1073
+ return /* @__PURE__ */ React.createElement(
1074
+ "div",
1039
1075
  {
1040
- value: draft,
1041
- onChange: (e) => setDraft(e.target.value),
1042
- onKeyDown: (e) => {
1043
- if (e.key === "Enter" && !e.shiftKey) {
1044
- e.preventDefault();
1045
- sendDraft();
1046
- }
1076
+ style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column", background: "var(--bg)", position: "relative" },
1077
+ onDragOver: (e) => {
1078
+ e.preventDefault();
1079
+ if (!dragOver) setDragOver(true);
1047
1080
  },
1048
- placeholder: `${T.message} ${peer.alias || shortKey(peer.userId, 6, 4)}\u2026`,
1049
- style: { flex: 1, height: 38, borderRadius: 9, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--ui)", fontSize: 13.5, padding: "0 14px", outline: "none", minWidth: 0 }
1050
- }
1051
- ), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "arrowUp", title: T.send, onClick: sendDraft }))));
1081
+ onDragLeave: (e) => {
1082
+ if (e.currentTarget === e.target) setDragOver(false);
1083
+ },
1084
+ onDrop
1085
+ },
1086
+ dragOver && /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", inset: 0, zIndex: 30, background: "rgba(91,140,255,0.10)", border: "2px dashed var(--accent)", borderRadius: 12, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", fontFamily: "var(--mono)", fontSize: 14, color: "var(--accent)" } }, lang === "zh" ? "\u677E\u5F00\u53D1\u9001\u6587\u4EF6" : "Drop to send file"),
1087
+ /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1088
+ setMenu(false);
1089
+ onAlias(peer);
1090
+ } }), /* @__PURE__ */ React.createElement("div", { style: { height: 1, background: "var(--line)", margin: "5px 4px" } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "trash", label: T.remove, danger: true, onClick: () => {
1091
+ setMenu(false);
1092
+ onRemove(peer);
1093
+ } }))))),
1094
+ /* @__PURE__ */ React.createElement("div", { ref: scrollRef, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto" } }, thread.map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(Msg, { key: i, m, peer, T })))),
1095
+ /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, borderTop: "1px solid var(--line)", padding: "12px 16px", background: "var(--panel)" } }, sending && /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto 8px", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "paperclip", size: 13, stroke: 2, color: "var(--accent)" }), (lang === "zh" ? "\u53D1\u9001\u4E2D: " : "Sending: ") + sending.name), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1096
+ "input",
1097
+ {
1098
+ ref: fileRef,
1099
+ type: "file",
1100
+ style: { display: "none" },
1101
+ onChange: (e) => {
1102
+ sendFiles(e.target.files);
1103
+ e.target.value = "";
1104
+ }
1105
+ }
1106
+ ), /* @__PURE__ */ React.createElement(Btn, { icon: "paperclip", title: T.sendFile, onClick: () => fileRef.current && fileRef.current.click() }), /* @__PURE__ */ React.createElement(
1107
+ "input",
1108
+ {
1109
+ value: draft,
1110
+ onChange: (e) => setDraft(e.target.value),
1111
+ onKeyDown: (e) => {
1112
+ if (e.key === "Enter" && !e.shiftKey) {
1113
+ e.preventDefault();
1114
+ sendDraft();
1115
+ }
1116
+ },
1117
+ placeholder: `${T.message} ${peer.alias || shortKey(peer.userId, 6, 4)}\u2026`,
1118
+ style: { flex: 1, height: 38, borderRadius: 9, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--ui)", fontSize: 13.5, padding: "0 14px", outline: "none", minWidth: 0 }
1119
+ }
1120
+ ), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "arrowUp", title: T.send, onClick: sendDraft })))
1121
+ );
1052
1122
  }
1053
1123
  function MenuItem({ icon, label, onClick, danger }) {
1054
1124
  return /* @__PURE__ */ React.createElement("button", { onClick, style: {
@@ -1071,9 +1141,9 @@ function MenuItem({ icon, label, onClick, danger }) {
1071
1141
  function ChatEmpty({ T }) {
1072
1142
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--faint)", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "message", size: 40, stroke: 1.4, color: "var(--line)" }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13 } }, T.pickPeer));
1073
1143
  }
1074
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onAlias, onRemove, onOpenNet }) {
1144
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
1075
1145
  const peer = peers.find((p) => p.id === activeId);
1076
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onAlias, onRemove, onOpenNet }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1146
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onSendFile, onAlias, onRemove, onOpenNet }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1077
1147
  }
1078
1148
  Object.assign(window, { ChatTab });
1079
1149
  function StatTile({ label, value, sub, tone }) {
@@ -1427,6 +1497,14 @@ function DkApp() {
1427
1497
  if (!activeId || !text || !text.trim()) return;
1428
1498
  dkApi.send(activeId, text.trim()).then(() => data.loadThread(activeId)).then(data.refresh);
1429
1499
  };
1500
+ const onSendFile = (file) => {
1501
+ if (!activeId || !file) return Promise.resolve({ ok: false });
1502
+ return dkApi.sendFile(activeId, file).then((r) => {
1503
+ data.loadThread(activeId);
1504
+ data.refresh();
1505
+ return r;
1506
+ });
1507
+ };
1430
1508
  const onAlias = (peer) => {
1431
1509
  const a = window.prompt("Set alias for this peer (empty to clear):", peer.alias || "");
1432
1510
  if (a !== null) dkApi.alias(peer.id, a).then(data.refresh);
@@ -1444,7 +1522,7 @@ function DkApp() {
1444
1522
  { id: "network", icon: "network", label: T.network },
1445
1523
  { id: "profile", icon: "userRound", label: T.profile }
1446
1524
  ];
1447
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "decentlan"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onAlias, onRemove, onOpenNet }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
1525
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "decentlan"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
1448
1526
  TweakRadio,
1449
1527
  {
1450
1528
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
@@ -17,6 +17,9 @@ export interface FriendUiOptions {
17
17
  wire?: string;
18
18
  channel?: string;
19
19
  };
20
+ /** Directory where the daemon saves received files (<configDir>/downloads).
21
+ * Used to serve GET /api/file-download. Undefined disables downloads. */
22
+ downloadsDir?: string;
20
23
  listenHost?: string;
21
24
  listenPort?: number;
22
25
  log?: (msg: string) => void;
package/dist/ui/server.js CHANGED
@@ -21,6 +21,115 @@ import { DEFAULT_EXITS } from "../config/loader.js";
21
21
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
22
22
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
23
23
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
24
+ /** True only when the request came from the local machine. Used to gate the
25
+ * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
26
+ * identity signing to other hosts. The popup always runs in the local user's
27
+ * browser, so it connects over the loopback interface. */
28
+ function isLocalRequest(req) {
29
+ const a = req.socket.remoteAddress ?? "";
30
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
31
+ }
32
+ /** Validate and normalize a website origin (scheme://host[:port], no path).
33
+ * Returns the canonical origin string or null if malformed. Only http/https
34
+ * are accepted. */
35
+ function validateOrigin(raw) {
36
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > 256)
37
+ return null;
38
+ try {
39
+ const u = new URL(raw);
40
+ if (u.protocol !== "http:" && u.protocol !== "https:")
41
+ return null;
42
+ // u.origin drops any path/query/hash and lowercases the host.
43
+ if (u.origin === "null" || !u.origin)
44
+ return null;
45
+ return u.origin;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ // The "Sign in with Decent" consent popup. Self-contained (no external assets),
52
+ // dark theme matching the desktop UI. Reads origin+nonce from its own query,
53
+ // shows the requesting site and this node's identity, and on Approve calls the
54
+ // local /api/connect-approve to sign, then postMessages the result to the
55
+ // opener at the exact requesting origin. Mandatory explicit consent — never
56
+ // returns identity without an Approve click.
57
+ const CONNECT_PAGE = `<!doctype html>
58
+ <html lang="en"><head><meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width,initial-scale=1">
60
+ <title>Sign in with Decent</title>
61
+ <style>
62
+ :root{--bg:#0c0d11;--panel:#14161d;--line:#262a35;--text:#e7e9ef;--faint:#8b91a3;
63
+ --accent:#5b8cff;--good:#46a758;--bad:#e5604d;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
64
+ *{box-sizing:border-box}
65
+ html,body{margin:0;height:100%}
66
+ body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
67
+ display:flex;align-items:center;justify-content:center;padding:18px}
68
+ .card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);
69
+ border-radius:14px;padding:22px 20px}
70
+ .brand{font-weight:600;letter-spacing:.2px;color:var(--accent);font-size:13px;display:flex;align-items:center;gap:7px}
71
+ .brand .d{width:11px;height:11px;background:var(--accent);transform:rotate(45deg);border-radius:2px}
72
+ h1{font-size:18px;margin:14px 0 6px}
73
+ .sub{color:var(--faint);margin:0 0 16px}
74
+ .origin{color:var(--text);font-weight:600;word-break:break-all}
75
+ .id{background:#0e1016;border:1px solid var(--line);border-radius:10px;padding:11px 12px;margin-bottom:14px}
76
+ .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--faint);margin-bottom:4px}
77
+ .userid{font-family:var(--mono);font-size:12.5px;color:var(--text);word-break:break-all}
78
+ .err{background:rgba(229,96,77,.12);border:1px solid rgba(229,96,77,.4);color:#ffb4a8;
79
+ border-radius:9px;padding:9px 11px;font-size:12.5px;margin-bottom:13px}
80
+ .row{display:flex;gap:10px;margin-top:4px}
81
+ .btn{flex:1;border:0;border-radius:9px;padding:11px;font-size:14px;font-weight:600;cursor:pointer}
82
+ .ghost{background:transparent;border:1px solid var(--line);color:var(--text)}
83
+ .ghost:hover{border-color:#3a4050}
84
+ .solid{background:var(--accent);color:#fff}
85
+ .solid:disabled{opacity:.45;cursor:not-allowed}
86
+ .note{color:var(--faint);font-size:11.5px;margin:15px 0 0;line-height:1.45}
87
+ </style></head>
88
+ <body>
89
+ <div class="card">
90
+ <div class="brand"><span class="d"></span>Decent</div>
91
+ <h1>Sign in</h1>
92
+ <p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your Decent identity.</p>
93
+ <div class="id"><div class="lbl">Your identity</div><div id="userid" class="userid">loading…</div></div>
94
+ <div id="err" class="err" hidden></div>
95
+ <div class="row">
96
+ <button id="deny" class="btn ghost">Deny</button>
97
+ <button id="approve" class="btn solid" disabled>Approve</button>
98
+ </div>
99
+ <p class="note">Approving sends the site a signature bound to that site, proving you control this identity. Your private key never leaves this device.</p>
100
+ </div>
101
+ <script>
102
+ (function(){
103
+ var qs=new URLSearchParams(location.search);
104
+ var origin=qs.get("origin")||"", nonce=qs.get("nonce")||"";
105
+ var oEl=document.getElementById("origin"), errEl=document.getElementById("err");
106
+ var approve=document.getElementById("approve"), deny=document.getElementById("deny");
107
+ oEl.textContent=origin||"(unknown site)";
108
+ function fail(m){errEl.textContent=m;errEl.hidden=false;}
109
+ function reply(data){ if(window.opener&&validOrigin){ window.opener.postMessage(Object.assign({type:"decent-auth",nonce:nonce},data),origin);} }
110
+ var validOrigin=false;
111
+ try{var u=new URL(origin); validOrigin=(u.protocol==="http:"||u.protocol==="https:")&&u.origin===origin;}catch(e){}
112
+ if(!validOrigin) fail("This sign-in request has an invalid origin and was blocked.");
113
+ if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This sign-in request is missing a valid nonce."); }
114
+ fetch("/api/state").then(function(r){return r.json();}).then(function(s){
115
+ var uid=(s.me&&s.me.userid)||"";
116
+ document.getElementById("userid").textContent=uid||"(no identity)";
117
+ if(uid&&validOrigin) approve.disabled=false;
118
+ else if(!uid) fail("No local Decent identity found — is the daemon running?");
119
+ }).catch(function(){ fail("Could not reach the local agentnet daemon."); });
120
+ deny.onclick=function(){ reply({error:"denied"}); setTimeout(function(){window.close();},60); };
121
+ approve.onclick=function(){
122
+ approve.disabled=true; approve.textContent="Signing…";
123
+ fetch("/api/connect-approve",{method:"POST",headers:{"content-type":"application/json"},
124
+ body:JSON.stringify({origin:origin,nonce:nonce})})
125
+ .then(function(r){return r.json();})
126
+ .then(function(j){ if(!j.ok) throw new Error(j.error||"sign failed");
127
+ reply({userid:j.userid,sig:j.sig}); setTimeout(function(){window.close();},60); })
128
+ .catch(function(e){ approve.disabled=false; approve.textContent="Approve"; fail("Signing failed: "+e.message); });
129
+ };
130
+ })();
131
+ </script>
132
+ </body></html>`;
24
133
  // ---- shapes the desktop UI (src/ui/desktop) consumes (DK_* in the design) ----
25
134
  const fmtTime = (ts) => {
26
135
  if (!ts)
@@ -94,6 +203,117 @@ export function startFriendUi(opts) {
94
203
  res.end("not found");
95
204
  return;
96
205
  }
206
+ // ── Sign in with Decent ────────────────────────────────────────────
207
+ // A website opens http://localhost:8765/connect?origin=…&nonce=… in a
208
+ // popup; on Approve we sign "decent-auth\n<origin>\n<nonce>" with this
209
+ // node's Carrier identity and postMessage {userid,nonce,sig} back to the
210
+ // site, which verifies the signature against the userid. BOTH routes are
211
+ // LOCALHOST-ONLY: the popup always runs in the local user's browser
212
+ // (localhost), so binding the UI to a LAN IP must never let a remote
213
+ // host request signatures with this identity.
214
+ if (url === "/connect" || url === "/api/connect-approve") {
215
+ if (!isLocalRequest(req)) {
216
+ res.writeHead(403, { "content-type": "text/plain" });
217
+ res.end("Sign in with Decent is available only on this machine (localhost).");
218
+ return;
219
+ }
220
+ }
221
+ if (req.method === "GET" && url === "/connect") {
222
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
223
+ res.end(CONNECT_PAGE);
224
+ return;
225
+ }
226
+ if (req.method === "POST" && url === "/api/connect-approve") {
227
+ const body = await readBody(req);
228
+ const origin = validateOrigin(body.origin);
229
+ const nonce = typeof body.nonce === "string" ? body.nonce : "";
230
+ if (!origin) {
231
+ sendJson(res, 400, { ok: false, error: "invalid origin" });
232
+ return;
233
+ }
234
+ if (!nonce || nonce.length > 512) {
235
+ sendJson(res, 400, { ok: false, error: "invalid nonce" });
236
+ return;
237
+ }
238
+ // Bind BOTH the origin and the nonce into the signed message so a
239
+ // signature minted for site A can't be replayed at site B.
240
+ const message = `decent-auth\n${origin}\n${nonce}`;
241
+ const r = await opts.call({ op: "sign", text: message });
242
+ if (!r.ok) {
243
+ sendJson(res, 502, { ok: false, error: r.error || "sign failed" });
244
+ return;
245
+ }
246
+ sendJson(res, 200, {
247
+ ok: true,
248
+ userid: r.data?.userid ?? "",
249
+ sig: r.data?.sig ?? "",
250
+ });
251
+ return;
252
+ }
253
+ // ── File transfer ──────────────────────────────────────────────────
254
+ // Upload: the browser POSTs raw file bytes with ?userid=&name= in the
255
+ // query. We stream them to a temp file (no in-memory buffering of large
256
+ // files) and hand the daemon that path over the existing file-send IPC.
257
+ if (req.method === "POST" && url === "/api/file-send") {
258
+ const q = new URL(req.url || "", "http://x").searchParams;
259
+ const userid = q.get("userid") || "";
260
+ const rawName = q.get("name") || "file";
261
+ const safe = rawName.replace(/[/\\]/g, "_").slice(0, 200) || "file";
262
+ if (!userid) {
263
+ sendJson(res, 400, { ok: false, error: "userid required" });
264
+ return;
265
+ }
266
+ const os = await import("node:os");
267
+ const fs = await import("node:fs");
268
+ const fsp = await import("node:fs/promises");
269
+ const tmpDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
270
+ const tmpPath = join(tmpDir, safe);
271
+ try {
272
+ await new Promise((resolve2, reject) => {
273
+ const ws = fs.createWriteStream(tmpPath);
274
+ req.pipe(ws);
275
+ req.on("error", reject);
276
+ ws.on("error", reject);
277
+ ws.on("finish", () => resolve2());
278
+ });
279
+ const r = await opts.call({ op: "file-send", userid, path: tmpPath });
280
+ if (!r.ok) {
281
+ sendJson(res, 502, r);
282
+ return;
283
+ }
284
+ sendJson(res, 200, { ok: true, ...(r.data ?? {}) });
285
+ }
286
+ catch (e) {
287
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
288
+ }
289
+ finally {
290
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
291
+ }
292
+ return;
293
+ }
294
+ // Download a received file by name, served from <configDir>/downloads.
295
+ if (req.method === "GET" && url === "/api/file-download") {
296
+ if (!opts.downloadsDir) {
297
+ res.writeHead(404);
298
+ res.end("downloads disabled");
299
+ return;
300
+ }
301
+ const q = new URL(req.url || "", "http://x").searchParams;
302
+ const name = (q.get("name") || "").replace(/[/\\]/g, "");
303
+ const file = join(opts.downloadsDir, name);
304
+ // Path-traversal guard: the resolved path must stay under downloadsDir.
305
+ if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
306
+ res.writeHead(404);
307
+ res.end("not found");
308
+ return;
309
+ }
310
+ res.writeHead(200, {
311
+ "content-type": "application/octet-stream",
312
+ "content-disposition": `attachment; filename="${name.replace(/"/g, "")}"`,
313
+ });
314
+ res.end(readFileSync(file));
315
+ return;
316
+ }
97
317
  if (req.method === "GET" && url === "/api/state") {
98
318
  const [diag, pending] = await Promise.all([
99
319
  opts.call({ op: "diag" }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.106",
3
+ "version": "0.1.108",
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.11",
82
- "@decentnetwork/peer": "^0.1.46",
82
+ "@decentnetwork/peer": "^0.1.47",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
85
  "react": "^18.3.1",