@decentnetwork/beagle 0.1.0
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/README.md +64 -0
- package/config/bootstrap-nodes.yaml +50 -0
- package/config/default-exits.yaml +49 -0
- package/dist/carrier-node.d.ts +108 -0
- package/dist/carrier-node.js +209 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +152 -0
- package/dist/desktop/app.js +3379 -0
- package/dist/desktop/index.html +55 -0
- package/dist/desktop/vendor/peer-webrtc.js +1070 -0
- package/dist/desktop/vendor/qrcode.js +2297 -0
- package/dist/desktop/vendor/react-dom.production.min.js +267 -0
- package/dist/desktop/vendor/react.production.min.js +31 -0
- package/dist/embedded-host.d.ts +37 -0
- package/dist/embedded-host.js +473 -0
- package/dist/exits.d.ts +7 -0
- package/dist/exits.js +24 -0
- package/dist/friend-meta.d.ts +36 -0
- package/dist/friend-meta.js +97 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/ipc.d.ts +41 -0
- package/dist/ipc.js +76 -0
- package/dist/logger.d.ts +20 -0
- package/dist/logger.js +43 -0
- package/dist/message-store.d.ts +104 -0
- package/dist/message-store.js +201 -0
- package/dist/node-config.d.ts +16 -0
- package/dist/node-config.js +53 -0
- package/dist/peer-host.d.ts +49 -0
- package/dist/peer-host.js +102 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +1187 -0
- package/package.json +45 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IpcRequest, IpcResponse } from "./ipc.js";
|
|
2
|
+
import type { PeerHost } from "./peer-host.js";
|
|
3
|
+
export interface EmbeddedHostOptions {
|
|
4
|
+
configDir: string;
|
|
5
|
+
keyFile: string;
|
|
6
|
+
bootstrapNodes: {
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
pk: string;
|
|
10
|
+
}[];
|
|
11
|
+
expressNodes?: {
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
pk: string;
|
|
15
|
+
tls?: boolean;
|
|
16
|
+
}[];
|
|
17
|
+
nickname?: string;
|
|
18
|
+
statusMessage?: string;
|
|
19
|
+
autoAcceptFriends: boolean;
|
|
20
|
+
onProfileChange?: (info: {
|
|
21
|
+
name?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}) => void;
|
|
24
|
+
onAutoAcceptChange?: (enabled: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare class EmbeddedHost implements PeerHost {
|
|
27
|
+
#private;
|
|
28
|
+
readonly kind: "embedded";
|
|
29
|
+
readonly hasVirtualLan = false;
|
|
30
|
+
constructor(opts: EmbeddedHostOptions);
|
|
31
|
+
get downloadsDir(): string;
|
|
32
|
+
get outboxDir(): string;
|
|
33
|
+
start(): Promise<void>;
|
|
34
|
+
subscribe(emit: (event: Record<string, unknown>) => void): () => void;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
call(req: IpcRequest): Promise<IpcResponse>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Backend A — beagle owns the Carrier peer in-process.
|
|
2
|
+
//
|
|
3
|
+
// No daemon, no TUN, no root. This is the backend a regular user gets: install
|
|
4
|
+
// beagle, run it, chat. It answers exactly the same op set as the decentlan
|
|
5
|
+
// daemon (see ipc.ts), so the HTTP layer and the entire UI are unchanged —
|
|
6
|
+
// which is what makes the two backends interchangeable.
|
|
7
|
+
//
|
|
8
|
+
// What is deliberately NOT here, versus the daemon: virtual IPs, exits, dora,
|
|
9
|
+
// ACL, packet routing. Those are decentlan's job and need privilege beagle
|
|
10
|
+
// refuses to ask for.
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
14
|
+
import { basename, resolve } from "node:path";
|
|
15
|
+
import { CarrierNode } from "./carrier-node.js";
|
|
16
|
+
import { MessageStore } from "./message-store.js";
|
|
17
|
+
import { FriendMetaStore } from "./friend-meta.js";
|
|
18
|
+
import { Logger } from "./logger.js";
|
|
19
|
+
/** Files bigger than this are not queued for an offline friend — the bytes sit
|
|
20
|
+
* on disk until they reconnect, and an unbounded queue is how you fill a
|
|
21
|
+
* user's disk by accident. Live transfers have no such limit. */
|
|
22
|
+
const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
23
|
+
/** Native (iOS/Android) clients receive files only as an inline envelope,
|
|
24
|
+
* which is base64 (~1.34x) inside a 16MB bulkmsg cap. */
|
|
25
|
+
const INLINE_MAX_BYTES = 11 * 1024 * 1024;
|
|
26
|
+
const MEDIA_RE = /\.(jpe?g|png|gif|webp|heic|bmp|svg|mp4|mov|m4v|webm|mp3|m4a|wav|aac|ogg|flac)$/i;
|
|
27
|
+
const isMediaFileName = (n) => MEDIA_RE.test(n);
|
|
28
|
+
/** Strip any directory component and characters that would let a peer-supplied
|
|
29
|
+
* name escape the downloads directory. A remote peer chooses this string. */
|
|
30
|
+
function sanitizeFileName(name) {
|
|
31
|
+
const base = basename(String(name || "file")).replace(/[/\\]/g, "_");
|
|
32
|
+
// Control-character class written with \u escapes on purpose: it was
|
|
33
|
+
// originally spelled with RAW control bytes, which every viewer renders as an
|
|
34
|
+
// innocuous "[ -]" -- it reads like a class matching spaces and hyphens, and
|
|
35
|
+
// that misreading cost a wrong bug diagnosis.
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
const cleaned = base.replace(/^\.+/, "").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
38
|
+
return cleaned.slice(0, 200) || "file";
|
|
39
|
+
}
|
|
40
|
+
export class EmbeddedHost {
|
|
41
|
+
kind = "embedded";
|
|
42
|
+
hasVirtualLan = false;
|
|
43
|
+
#node = new CarrierNode();
|
|
44
|
+
#messages;
|
|
45
|
+
#meta;
|
|
46
|
+
#logger = new Logger({ prefix: "Beagle" });
|
|
47
|
+
#opts;
|
|
48
|
+
#events = new EventEmitter();
|
|
49
|
+
/** Friend requests held for manual accept when auto-accept is off. */
|
|
50
|
+
#pending = new Map();
|
|
51
|
+
/** Inbound call signals waiting for the UI's long-poll to collect them. */
|
|
52
|
+
#callQueue = [];
|
|
53
|
+
#callWaiters = [];
|
|
54
|
+
/** fileId -> which chat message it belongs to, so progress can patch it. */
|
|
55
|
+
#activeSends = new Map();
|
|
56
|
+
#autoAccept;
|
|
57
|
+
constructor(opts) {
|
|
58
|
+
this.#opts = opts;
|
|
59
|
+
this.#autoAccept = opts.autoAcceptFriends;
|
|
60
|
+
this.#messages = new MessageStore(resolve(opts.configDir, "messages.json"));
|
|
61
|
+
this.#meta = new FriendMetaStore(resolve(opts.configDir, "friends-meta.json"));
|
|
62
|
+
}
|
|
63
|
+
get downloadsDir() {
|
|
64
|
+
return resolve(this.#opts.configDir, "downloads");
|
|
65
|
+
}
|
|
66
|
+
get outboxDir() {
|
|
67
|
+
return resolve(this.#opts.configDir, "outbox");
|
|
68
|
+
}
|
|
69
|
+
async start() {
|
|
70
|
+
await this.#node.create({
|
|
71
|
+
keyFile: this.#opts.keyFile,
|
|
72
|
+
bootstrapNodes: this.#opts.bootstrapNodes,
|
|
73
|
+
expressNodes: this.#opts.expressNodes,
|
|
74
|
+
nickname: this.#opts.nickname,
|
|
75
|
+
statusMessage: this.#opts.statusMessage,
|
|
76
|
+
fileResumeDir: resolve(this.#opts.configDir, "file-resume"),
|
|
77
|
+
});
|
|
78
|
+
this.#wire();
|
|
79
|
+
await this.#node.start();
|
|
80
|
+
await this.#node.join();
|
|
81
|
+
await mkdir(this.downloadsDir, { recursive: true }).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
#wire() {
|
|
84
|
+
this.#node.on("message", (pubkey, text, via) => {
|
|
85
|
+
// No status on an inbound message — MessageStore's status field tracks
|
|
86
|
+
// OUR delivery state (queued|sent|failed). `via` is the inbound signal:
|
|
87
|
+
// whether it arrived live or was pulled from offline storage.
|
|
88
|
+
this.#messages.append(pubkey, "in", text, Date.now(), undefined, via);
|
|
89
|
+
this.#meta.ensure(pubkey);
|
|
90
|
+
this.#events.emit("event", { type: "chat", userid: pubkey, dir: "in" });
|
|
91
|
+
});
|
|
92
|
+
this.#node.on("friend-request", (req) => {
|
|
93
|
+
const userid = req.userid ?? req.pubkey;
|
|
94
|
+
if (this.#autoAccept) {
|
|
95
|
+
void this.#node
|
|
96
|
+
.acceptFriendRequest(req.pubkey)
|
|
97
|
+
.then(() => this.#logger.info(`auto-accepted friend request from ${userid.slice(0, 8)}`))
|
|
98
|
+
.catch((e) => this.#logger.warn(`accept failed: ${e.message}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.#pending.set(userid, {
|
|
102
|
+
userid,
|
|
103
|
+
pubkey: req.pubkey,
|
|
104
|
+
name: req.name,
|
|
105
|
+
hello: req.hello,
|
|
106
|
+
arrivedAt: Date.now(),
|
|
107
|
+
});
|
|
108
|
+
this.#events.emit("event", { type: "friend-request", userid });
|
|
109
|
+
});
|
|
110
|
+
this.#node.on("friend-connection", (e) => {
|
|
111
|
+
this.#events.emit("event", { type: "presence", userid: e.pubkey, status: e.status });
|
|
112
|
+
if (e.status === "connected")
|
|
113
|
+
void this.#flushOutbox(e.pubkey);
|
|
114
|
+
});
|
|
115
|
+
this.#node.on("call-signal", (evt) => {
|
|
116
|
+
this.#callQueue.push({ userid: evt.pubkey, data: Buffer.from(evt.data).toString("utf-8") });
|
|
117
|
+
// Release every long-poll waiting on this queue.
|
|
118
|
+
const waiters = this.#callWaiters.splice(0);
|
|
119
|
+
for (const w of waiters)
|
|
120
|
+
w();
|
|
121
|
+
});
|
|
122
|
+
// Inline files (the path native iOS/Android peers use).
|
|
123
|
+
this.#node.on("inline-file", (f) => {
|
|
124
|
+
void this.#saveIncoming(f.pubkey, f.name, Buffer.from(f.data));
|
|
125
|
+
});
|
|
126
|
+
// toxcore file transfer. The daemon logs an incoming file once, on
|
|
127
|
+
// completion — no "receiving" chip — and MessageStore's status enum has no
|
|
128
|
+
// such state anyway (queued|sending|sent|failed|cancelled). Match it.
|
|
129
|
+
this.#node.on("file-offer", (o) => {
|
|
130
|
+
this.#logger.info(`incoming "${o.name}" (${o.size}B) from ${o.friendId.slice(0, 8)} — accepting`);
|
|
131
|
+
try {
|
|
132
|
+
// Friend-only network: adding the friend was the trust decision.
|
|
133
|
+
this.#node.acceptFile(o.friendId, o.fileNumber);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
this.#logger.warn(`could not accept file: ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
this.#node.on("file-progress", (p) => {
|
|
140
|
+
// `sending` is authoritative for direction — no map scanning needed.
|
|
141
|
+
if (!p.sending)
|
|
142
|
+
return;
|
|
143
|
+
const s = this.#activeSends.get(p.fileId);
|
|
144
|
+
if (s)
|
|
145
|
+
this.#messages.patchFile(s.peer, s.msgId, { sent: p.received });
|
|
146
|
+
});
|
|
147
|
+
this.#node.on("file-complete", (p) => {
|
|
148
|
+
if (p.sending) {
|
|
149
|
+
const s = this.#activeSends.get(p.fileId);
|
|
150
|
+
if (!s)
|
|
151
|
+
return;
|
|
152
|
+
this.#messages.patchFile(s.peer, s.msgId, { status: "sent", sent: p.size });
|
|
153
|
+
this.#activeSends.delete(p.fileId);
|
|
154
|
+
void unlink(resolve(this.outboxDir, s.msgId)).catch(() => undefined);
|
|
155
|
+
this.#events.emit("event", { type: "chat", userid: s.peer, dir: "out" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Receiving side: data is present here.
|
|
159
|
+
if (p.data)
|
|
160
|
+
void this.#saveIncoming(p.friendId, p.name, Buffer.from(p.data));
|
|
161
|
+
});
|
|
162
|
+
this.#node.on("file-cancel", (p) => {
|
|
163
|
+
if (!p.sending)
|
|
164
|
+
return;
|
|
165
|
+
const s = this.#activeSends.get(p.fileId);
|
|
166
|
+
if (!s)
|
|
167
|
+
return;
|
|
168
|
+
this.#messages.patchFile(s.peer, s.msgId, { status: "cancelled" });
|
|
169
|
+
this.#activeSends.delete(p.fileId);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Persist a received file under downloads/ and log it in the thread. */
|
|
173
|
+
async #saveIncoming(pubkey, name, data) {
|
|
174
|
+
const safe = sanitizeFileName(name);
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(this.downloadsDir, { recursive: true });
|
|
177
|
+
await writeFile(resolve(this.downloadsDir, safe), data);
|
|
178
|
+
this.#messages.appendFile(pubkey, "in", { name: safe, size: data.length });
|
|
179
|
+
this.#meta.ensure(pubkey);
|
|
180
|
+
this.#events.emit("event", { type: "chat", userid: pubkey, dir: "in" });
|
|
181
|
+
this.#logger.info(`saved "${safe}" (${data.length}B) from ${pubkey.slice(0, 8)}`);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
this.#logger.warn(`could not save incoming file: ${e.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Deliver anything queued while this friend was offline. */
|
|
188
|
+
async #flushOutbox(pubkey) {
|
|
189
|
+
// queuedOutgoing() exists for exactly this — it returns the queued text and
|
|
190
|
+
// file chips oldest-first. Reading history() and pattern-matching its shape
|
|
191
|
+
// would couple this to the store's serialization format for no reason.
|
|
192
|
+
for (const msg of this.#messages.queuedOutgoing(pubkey)) {
|
|
193
|
+
if (!msg.file)
|
|
194
|
+
continue;
|
|
195
|
+
const path = resolve(this.outboxDir, msg.id);
|
|
196
|
+
if (!existsSync(path))
|
|
197
|
+
continue;
|
|
198
|
+
try {
|
|
199
|
+
const data = await readFile(path);
|
|
200
|
+
const fileId = this.#node.sendFile(pubkey, new Uint8Array(data), msg.file.name);
|
|
201
|
+
if (fileId) {
|
|
202
|
+
this.#messages.patchFile(pubkey, msg.id, { status: "sending", sent: 0 });
|
|
203
|
+
this.#activeSends.set(fileId, { peer: pubkey, msgId: msg.id });
|
|
204
|
+
this.#logger.info(`delivering queued "${msg.file.name}" to ${pubkey.slice(0, 8)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
this.#logger.warn(`outbox flush failed: ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
subscribe(emit) {
|
|
213
|
+
const fn = (e) => emit(e);
|
|
214
|
+
this.#events.on("event", fn);
|
|
215
|
+
return () => this.#events.off("event", fn);
|
|
216
|
+
}
|
|
217
|
+
async stop() {
|
|
218
|
+
await this.#node.stop();
|
|
219
|
+
}
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
// The op surface — same contract as the daemon's IPC.
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
async call(req) {
|
|
224
|
+
try {
|
|
225
|
+
const data = await this.#dispatch(req);
|
|
226
|
+
return { ok: true, data: data ?? {} };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
return { ok: false, error: error.message };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async #dispatch(req) {
|
|
233
|
+
const uid = (req.userid ?? "");
|
|
234
|
+
switch (req.op) {
|
|
235
|
+
case "ping":
|
|
236
|
+
return { ok: true };
|
|
237
|
+
case "diag": {
|
|
238
|
+
const id = this.#node.identity();
|
|
239
|
+
return {
|
|
240
|
+
identity: id,
|
|
241
|
+
node: { name: this.#opts.nickname ?? "", backend: "embedded" },
|
|
242
|
+
// No TUN in this backend — say so explicitly rather than omitting
|
|
243
|
+
// the key, so the UI can distinguish "no LAN" from "unknown".
|
|
244
|
+
tun: null,
|
|
245
|
+
allocatedIp: null,
|
|
246
|
+
dht: this.#node.dhtHealth(),
|
|
247
|
+
friends: this.#node.friends().map((f) => ({
|
|
248
|
+
...f,
|
|
249
|
+
session: this.#node.sessionStatus(f.pubkey),
|
|
250
|
+
})),
|
|
251
|
+
ipam: [],
|
|
252
|
+
proxy: { enabled: false },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
case "friends-list": {
|
|
256
|
+
const last = this.#messages.lastMessages();
|
|
257
|
+
return {
|
|
258
|
+
friends: this.#node.friends().map((f) => {
|
|
259
|
+
const meta = this.#meta.get(f.carrierId);
|
|
260
|
+
const lastMsg = last.get(f.carrierId);
|
|
261
|
+
return {
|
|
262
|
+
userid: f.carrierId,
|
|
263
|
+
alias: meta?.alias,
|
|
264
|
+
name: meta?.alias || f.name || f.carrierId,
|
|
265
|
+
status: f.status,
|
|
266
|
+
lastSeen: f.acceptedAt,
|
|
267
|
+
pinned: meta?.pinned ?? false,
|
|
268
|
+
lastMessage: lastMsg ? { dir: lastMsg.dir, text: lastMsg.text, ts: lastMsg.ts } : undefined,
|
|
269
|
+
unread: this.#messages.unreadCount(f.carrierId, meta?.lastReadTs ?? 0),
|
|
270
|
+
};
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "friend-request":
|
|
275
|
+
await this.#node.sendFriendRequest(String(req.address ?? ""), req.hello);
|
|
276
|
+
return {};
|
|
277
|
+
case "friends-pending":
|
|
278
|
+
return { pending: [...this.#pending.values()] };
|
|
279
|
+
case "friends-accept": {
|
|
280
|
+
const entry = this.#pending.get(uid);
|
|
281
|
+
if (entry) {
|
|
282
|
+
await this.#node.acceptFriendRequest(entry.pubkey);
|
|
283
|
+
this.#pending.delete(uid);
|
|
284
|
+
}
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
case "friends-reject":
|
|
288
|
+
this.#pending.delete(uid);
|
|
289
|
+
return {};
|
|
290
|
+
case "friends-autoaccept": {
|
|
291
|
+
this.#autoAccept = Boolean(req.enabled);
|
|
292
|
+
this.#opts.onAutoAcceptChange?.(this.#autoAccept);
|
|
293
|
+
return { autoAccept: this.#autoAccept };
|
|
294
|
+
}
|
|
295
|
+
case "friend-remove":
|
|
296
|
+
this.#node.removeFriend(uid);
|
|
297
|
+
this.#messages.removePeer(uid);
|
|
298
|
+
this.#meta.remove(uid);
|
|
299
|
+
return {};
|
|
300
|
+
case "friend-set-alias":
|
|
301
|
+
this.#meta.setAlias(uid, req.alias);
|
|
302
|
+
return {};
|
|
303
|
+
case "set-profile": {
|
|
304
|
+
const info = { name: req.name, description: req.description };
|
|
305
|
+
this.#node.setUserInfo(info);
|
|
306
|
+
if (info.name)
|
|
307
|
+
this.#opts.nickname = info.name;
|
|
308
|
+
if (info.description !== undefined)
|
|
309
|
+
this.#opts.statusMessage = info.description;
|
|
310
|
+
this.#opts.onProfileChange?.(info);
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
case "sign": {
|
|
314
|
+
const sig = this.#node.sign(new TextEncoder().encode(String(req.text ?? "")));
|
|
315
|
+
return { sig: Buffer.from(sig).toString("hex"), userid: this.#node.identity().userid };
|
|
316
|
+
}
|
|
317
|
+
case "chat-send": {
|
|
318
|
+
const text = String(req.text ?? "");
|
|
319
|
+
await this.#node.sendText(uid, text);
|
|
320
|
+
this.#messages.append(uid, "out", text, Date.now(), "sent");
|
|
321
|
+
this.#meta.ensure(uid);
|
|
322
|
+
this.#events.emit("event", { type: "chat", userid: uid, dir: "out" });
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
case "chat-log-local":
|
|
326
|
+
this.#messages.append(uid, req.dir ?? "in", String(req.text ?? ""), Date.now());
|
|
327
|
+
this.#meta.ensure(uid);
|
|
328
|
+
return {};
|
|
329
|
+
case "file-log-local":
|
|
330
|
+
this.#messages.appendFile(uid, req.dir ?? "in", {
|
|
331
|
+
name: sanitizeFileName(String(req.name ?? "file")),
|
|
332
|
+
size: Number(req.size ?? 0),
|
|
333
|
+
});
|
|
334
|
+
this.#meta.ensure(uid);
|
|
335
|
+
return {};
|
|
336
|
+
case "chat-history":
|
|
337
|
+
return {
|
|
338
|
+
chats: this.#messages.history(req.peer, {
|
|
339
|
+
before: req.before,
|
|
340
|
+
limit: req.limit,
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
case "chat-mark-read":
|
|
344
|
+
this.#meta.markRead(uid, req.ts ?? Date.now());
|
|
345
|
+
return {};
|
|
346
|
+
case "file-send":
|
|
347
|
+
return this.#fileSend(uid, String(req.path ?? ""));
|
|
348
|
+
case "file-delete": {
|
|
349
|
+
const removed = this.#messages.deleteMessages(req.peer, req.ids ?? []);
|
|
350
|
+
for (const m of removed) {
|
|
351
|
+
if (!m.file?.name)
|
|
352
|
+
continue;
|
|
353
|
+
void unlink(resolve(this.downloadsDir, sanitizeFileName(m.file.name))).catch(() => undefined);
|
|
354
|
+
}
|
|
355
|
+
return { removed: removed.length };
|
|
356
|
+
}
|
|
357
|
+
case "file-cancel": {
|
|
358
|
+
let cancelled = 0;
|
|
359
|
+
for (const id of req.ids ?? []) {
|
|
360
|
+
for (const [fileId, s] of this.#activeSends) {
|
|
361
|
+
if (s.msgId !== id)
|
|
362
|
+
continue;
|
|
363
|
+
if (this.#node.cancelSend(s.peer, fileId))
|
|
364
|
+
cancelled++;
|
|
365
|
+
this.#activeSends.delete(fileId);
|
|
366
|
+
this.#messages.patchFile(s.peer, id, { status: "failed" });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { cancelled };
|
|
370
|
+
}
|
|
371
|
+
case "file-retry": {
|
|
372
|
+
let retried = 0;
|
|
373
|
+
for (const id of req.ids ?? []) {
|
|
374
|
+
const path = resolve(this.outboxDir, id);
|
|
375
|
+
if (!existsSync(path))
|
|
376
|
+
continue;
|
|
377
|
+
const data = await readFile(path);
|
|
378
|
+
const fileId = this.#node.sendFile(req.peer, new Uint8Array(data), `retry-${id}`);
|
|
379
|
+
if (fileId) {
|
|
380
|
+
this.#activeSends.set(fileId, { peer: req.peer, msgId: id });
|
|
381
|
+
this.#messages.patchFile(req.peer, id, { status: "sending", sent: 0 });
|
|
382
|
+
retried++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { retried };
|
|
386
|
+
}
|
|
387
|
+
case "call-signal":
|
|
388
|
+
await this.#node.sendCallSignal(uid, String(req.text ?? req.data ?? ""));
|
|
389
|
+
return {};
|
|
390
|
+
case "call-poll":
|
|
391
|
+
return { signals: await this.#pollCalls() };
|
|
392
|
+
default:
|
|
393
|
+
throw new Error(`Unsupported op in embedded backend: ${req.op}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Long-poll: return immediately if signals are queued, else hold ~20s. The
|
|
397
|
+
* UI re-polls, which gives near-instant offer/answer delivery without a
|
|
398
|
+
* WebSocket. Matches the daemon's behaviour exactly so call.jsx is unchanged. */
|
|
399
|
+
async #pollCalls() {
|
|
400
|
+
if (this.#callQueue.length)
|
|
401
|
+
return this.#callQueue.splice(0);
|
|
402
|
+
await new Promise((res) => {
|
|
403
|
+
const done = () => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
res();
|
|
406
|
+
};
|
|
407
|
+
const timer = setTimeout(() => {
|
|
408
|
+
const i = this.#callWaiters.indexOf(done);
|
|
409
|
+
if (i >= 0)
|
|
410
|
+
this.#callWaiters.splice(i, 1);
|
|
411
|
+
res();
|
|
412
|
+
}, 20_000);
|
|
413
|
+
timer.unref?.();
|
|
414
|
+
this.#callWaiters.push(done);
|
|
415
|
+
});
|
|
416
|
+
return this.#callQueue.splice(0);
|
|
417
|
+
}
|
|
418
|
+
async #fileSend(userid, path) {
|
|
419
|
+
const data = await readFile(path);
|
|
420
|
+
const name = basename(path);
|
|
421
|
+
const safe = sanitizeFileName(name);
|
|
422
|
+
// Keep a local copy of outgoing media so the sender can preview it too —
|
|
423
|
+
// the UI uploads to a temp file that is deleted right after send.
|
|
424
|
+
if (isMediaFileName(safe)) {
|
|
425
|
+
void (async () => {
|
|
426
|
+
try {
|
|
427
|
+
await mkdir(this.downloadsDir, { recursive: true });
|
|
428
|
+
await writeFile(resolve(this.downloadsDir, safe), data);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// preview cache is best-effort
|
|
432
|
+
}
|
|
433
|
+
})();
|
|
434
|
+
}
|
|
435
|
+
if (!this.#node.isFriendOnline(userid)) {
|
|
436
|
+
if (data.length > OUTBOX_MAX_FILE_BYTES) {
|
|
437
|
+
throw new Error(`Peer is offline — queued files are limited to ${(OUTBOX_MAX_FILE_BYTES / 1024 / 1024) | 0} MB ` +
|
|
438
|
+
`(this is ${(data.length / 1024 / 1024).toFixed(1)} MB). Send it once they're online.`);
|
|
439
|
+
}
|
|
440
|
+
const msg = this.#messages.appendFile(userid, "out", { name: safe, size: data.length, status: "queued", sent: 0 });
|
|
441
|
+
if (!msg)
|
|
442
|
+
throw new Error("Could not queue file");
|
|
443
|
+
await mkdir(this.outboxDir, { recursive: true });
|
|
444
|
+
await writeFile(resolve(this.outboxDir, msg.id), data);
|
|
445
|
+
this.#meta.ensure(userid);
|
|
446
|
+
this.#events.emit("event", { type: "chat", userid, dir: "out" });
|
|
447
|
+
return { queued: true, name: safe, size: data.length };
|
|
448
|
+
}
|
|
449
|
+
const msg = this.#messages.appendFile(userid, "out", { name: safe, size: data.length, status: "sending", sent: 0 });
|
|
450
|
+
if (!msg)
|
|
451
|
+
throw new Error("Could not create file message");
|
|
452
|
+
await mkdir(this.outboxDir, { recursive: true });
|
|
453
|
+
await writeFile(resolve(this.outboxDir, msg.id), data);
|
|
454
|
+
const fileId = this.#node.sendFile(userid, new Uint8Array(data), safe);
|
|
455
|
+
if (!fileId) {
|
|
456
|
+
// No transfer slot — try the inline envelope, which is also the only
|
|
457
|
+
// path a native iOS/Android friend can receive at all.
|
|
458
|
+
if (data.length > INLINE_MAX_BYTES) {
|
|
459
|
+
this.#messages.patchFile(userid, msg.id, { status: "failed" });
|
|
460
|
+
throw new Error(`Could not start the transfer, and the file is too large (${(data.length / 1024 / 1024).toFixed(1)} MB) ` +
|
|
461
|
+
`for the inline fallback (limit ${(INLINE_MAX_BYTES / 1024 / 1024).toFixed(1)} MB).`);
|
|
462
|
+
}
|
|
463
|
+
await this.#node.sendInlineFile(userid, new Uint8Array(data), safe);
|
|
464
|
+
this.#messages.patchFile(userid, msg.id, { status: "sent", sent: data.length });
|
|
465
|
+
this.#events.emit("event", { type: "chat", userid, dir: "out" });
|
|
466
|
+
return { inline: true, name: safe, size: data.length };
|
|
467
|
+
}
|
|
468
|
+
this.#activeSends.set(fileId, { peer: userid, msgId: msg.id });
|
|
469
|
+
this.#meta.ensure(userid);
|
|
470
|
+
this.#events.emit("event", { type: "chat", userid, dir: "out" });
|
|
471
|
+
return { fileId, name: safe, size: data.length };
|
|
472
|
+
}
|
|
473
|
+
}
|
package/dist/exits.d.ts
ADDED
package/dist/exits.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The official exit-node table, used only by the network panel (which itself
|
|
2
|
+
// only renders when a decentlan daemon is providing the virtual LAN).
|
|
3
|
+
//
|
|
4
|
+
// Shipped as data in this repo rather than imported from @decentnetwork/lan:
|
|
5
|
+
// that package exports only ".", and this is a small static table. Phase 3 of
|
|
6
|
+
// the split moves the whole network panel behind the backend-B check, at which
|
|
7
|
+
// point this file follows it.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
function load() {
|
|
13
|
+
try {
|
|
14
|
+
// dist/exits.js -> ../config/default-exits.yaml
|
|
15
|
+
const file = resolve(dirname(fileURLToPath(import.meta.url)), "..", "config", "default-exits.yaml");
|
|
16
|
+
const parsed = yaml.load(readFileSync(file, "utf-8"));
|
|
17
|
+
return (parsed?.exits ?? []).filter((e) => e && e.userid);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// A missing table degrades the exits list, never the app.
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const DEFAULT_EXITS = load();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk friend metadata — local-only UI state that the Carrier friend store
|
|
3
|
+
* doesn't hold (PRD-DESKTOP-UI §2.2): a local alias, a read cursor for unread
|
|
4
|
+
* badges, pin/order, and when we added them.
|
|
5
|
+
*
|
|
6
|
+
* File: <configDir>/friends-meta.json -> { userid: FriendMeta }
|
|
7
|
+
* Keyed by userid. Identity/accept-state still lives in the Carrier friend
|
|
8
|
+
* store; this only augments it for display.
|
|
9
|
+
*/
|
|
10
|
+
export interface FriendMeta {
|
|
11
|
+
userid: string;
|
|
12
|
+
/** Local display name; falls back to the userid in the UI when absent. */
|
|
13
|
+
alias?: string;
|
|
14
|
+
pinned?: boolean;
|
|
15
|
+
/** ts of the newest message the user has seen — drives unread counts. */
|
|
16
|
+
lastReadTs?: number;
|
|
17
|
+
addedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class FriendMetaStore {
|
|
20
|
+
private path;
|
|
21
|
+
private byUserid;
|
|
22
|
+
private logger;
|
|
23
|
+
private saveTimer?;
|
|
24
|
+
private dirty;
|
|
25
|
+
constructor(path: string);
|
|
26
|
+
private load;
|
|
27
|
+
get(userid: string): FriendMeta;
|
|
28
|
+
/** Ensure an entry exists (first time we see a friend). */
|
|
29
|
+
ensure(userid: string): FriendMeta;
|
|
30
|
+
setAlias(userid: string, alias: string | undefined): void;
|
|
31
|
+
markRead(userid: string, ts?: number): void;
|
|
32
|
+
setPinned(userid: string, pinned: boolean): void;
|
|
33
|
+
remove(userid: string): void;
|
|
34
|
+
private scheduleSave;
|
|
35
|
+
flush(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk friend metadata — local-only UI state that the Carrier friend store
|
|
3
|
+
* doesn't hold (PRD-DESKTOP-UI §2.2): a local alias, a read cursor for unread
|
|
4
|
+
* badges, pin/order, and when we added them.
|
|
5
|
+
*
|
|
6
|
+
* File: <configDir>/friends-meta.json -> { userid: FriendMeta }
|
|
7
|
+
* Keyed by userid. Identity/accept-state still lives in the Carrier friend
|
|
8
|
+
* store; this only augments it for display.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
11
|
+
import { Logger } from "./logger.js";
|
|
12
|
+
export class FriendMetaStore {
|
|
13
|
+
path;
|
|
14
|
+
byUserid = new Map();
|
|
15
|
+
logger = new Logger({ prefix: "FriendMeta" });
|
|
16
|
+
saveTimer;
|
|
17
|
+
dirty = false;
|
|
18
|
+
constructor(path) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.load();
|
|
21
|
+
}
|
|
22
|
+
load() {
|
|
23
|
+
if (!existsSync(this.path))
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
27
|
+
for (const [userid, meta] of Object.entries(raw)) {
|
|
28
|
+
if (meta && typeof meta === "object")
|
|
29
|
+
this.byUserid.set(userid, { ...meta, userid });
|
|
30
|
+
}
|
|
31
|
+
this.logger.info(`Loaded metadata for ${this.byUserid.size} friends`);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
this.logger.warn(`Could not load ${this.path}: ${err}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
get(userid) {
|
|
38
|
+
return this.byUserid.get(userid) ?? { userid, addedAt: Date.now() };
|
|
39
|
+
}
|
|
40
|
+
/** Ensure an entry exists (first time we see a friend). */
|
|
41
|
+
ensure(userid) {
|
|
42
|
+
let m = this.byUserid.get(userid);
|
|
43
|
+
if (!m) {
|
|
44
|
+
m = { userid, addedAt: Date.now() };
|
|
45
|
+
this.byUserid.set(userid, m);
|
|
46
|
+
this.scheduleSave();
|
|
47
|
+
}
|
|
48
|
+
return m;
|
|
49
|
+
}
|
|
50
|
+
setAlias(userid, alias) {
|
|
51
|
+
const m = this.ensure(userid);
|
|
52
|
+
m.alias = alias && alias.trim() ? alias.trim() : undefined;
|
|
53
|
+
this.scheduleSave();
|
|
54
|
+
}
|
|
55
|
+
markRead(userid, ts = Date.now()) {
|
|
56
|
+
const m = this.ensure(userid);
|
|
57
|
+
if (!m.lastReadTs || ts > m.lastReadTs) {
|
|
58
|
+
m.lastReadTs = ts;
|
|
59
|
+
this.scheduleSave();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
setPinned(userid, pinned) {
|
|
63
|
+
const m = this.ensure(userid);
|
|
64
|
+
m.pinned = pinned || undefined;
|
|
65
|
+
this.scheduleSave();
|
|
66
|
+
}
|
|
67
|
+
remove(userid) {
|
|
68
|
+
if (this.byUserid.delete(userid))
|
|
69
|
+
this.scheduleSave();
|
|
70
|
+
}
|
|
71
|
+
scheduleSave() {
|
|
72
|
+
this.dirty = true;
|
|
73
|
+
if (this.saveTimer)
|
|
74
|
+
return;
|
|
75
|
+
this.saveTimer = setTimeout(() => {
|
|
76
|
+
this.saveTimer = undefined;
|
|
77
|
+
this.flush();
|
|
78
|
+
}, 1000);
|
|
79
|
+
this.saveTimer.unref?.();
|
|
80
|
+
}
|
|
81
|
+
flush() {
|
|
82
|
+
if (!this.dirty)
|
|
83
|
+
return;
|
|
84
|
+
this.dirty = false;
|
|
85
|
+
try {
|
|
86
|
+
const obj = {};
|
|
87
|
+
for (const [u, m] of this.byUserid)
|
|
88
|
+
obj[u] = m;
|
|
89
|
+
const tmp = `${this.path}.tmp`;
|
|
90
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
91
|
+
renameSync(tmp, this.path);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
this.logger.warn(`Could not save ${this.path}: ${err}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { startBeagleServer, type BeagleServerOptions } from "./server.js";
|
|
2
|
+
export { openPeerHost, defaultConfigDir, decentlanCarrierDir, type PeerHost, type BackendKind } from "./peer-host.js";
|
|
3
|
+
export { ipcCall, ipcSocketPath, daemonIsRunning, type IpcRequest, type IpcResponse, type IpcOp } from "./ipc.js";
|