@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
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Beagle
|
|
2
|
+
|
|
3
|
+
P2P chat, file transfer and calls over the [Decent Network](https://github.com/0xli/decentlan).
|
|
4
|
+
No account, no server, no admin privilege — your identity is a keypair on your
|
|
5
|
+
own machine, and messages go peer-to-peer.
|
|
6
|
+
|
|
7
|
+
Beagle is the **application**. The protocol lives in `@decentnetwork/peer` (SDK)
|
|
8
|
+
and the virtual-LAN layer in `@decentnetwork/lan` (decentlan, a developer and
|
|
9
|
+
operator tool). Beagle iterates fast; those stay boring on purpose.
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm run build
|
|
16
|
+
node dist/cli.js
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then open <http://localhost:8766>.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
--port <n> HTTP port (default 8766)
|
|
23
|
+
--host <addr> bind address (default 127.0.0.1)
|
|
24
|
+
--config-dir <p> identity/config dir (default ~/.agentnet)
|
|
25
|
+
--dora-dir <p> dora roster dir, if this machine runs a dora
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Port 8766 rather than decentlan's 8765, so during the transition the old
|
|
29
|
+
`agentnet ui` and Beagle can run side by side on one machine.
|
|
30
|
+
|
|
31
|
+
## Status: Phase 1
|
|
32
|
+
|
|
33
|
+
Beagle picks a **backend** for its Carrier identity. Exactly one is ever live,
|
|
34
|
+
because one identity must never run two Carrier peers at once — two peers on the
|
|
35
|
+
same keypair race on session establishment and corrupt each other's state.
|
|
36
|
+
|
|
37
|
+
| Backend | Status | Needs root | Virtual LAN |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `daemon` — talks to a running decentlan daemon over IPC | **shipping** | no (the daemon does) | yes |
|
|
40
|
+
| `embedded` — Beagle opens its own Peer in-process | Phase 2 | **no** | no |
|
|
41
|
+
|
|
42
|
+
Today Beagle requires a decentlan daemon. Phase 2 adds the embedded backend, at
|
|
43
|
+
which point Beagle runs standalone at ordinary user privilege and decentlan
|
|
44
|
+
becomes the optional "I also want a virtual LAN" add-on.
|
|
45
|
+
|
|
46
|
+
Identity is read from `~/.agentnet` by default, so a machine already running
|
|
47
|
+
decentlan keeps the same userid and the same friends — it is the same node,
|
|
48
|
+
not a new one.
|
|
49
|
+
|
|
50
|
+
## Layout
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
src/
|
|
54
|
+
├── cli.ts # the `beagle` binary — argument parsing, startup
|
|
55
|
+
├── peer-host.ts # backend abstraction + selection (the split's hinge)
|
|
56
|
+
├── ipc.ts # decentlan daemon wire protocol, client side
|
|
57
|
+
├── server.ts # local HTTP server: /api/* + serves the UI bundle
|
|
58
|
+
├── exits.ts # official exit table (network panel only)
|
|
59
|
+
└── desktop/ # the UI itself (JSX, concatenated + esbuild-transpiled)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`scripts/build-ui.mjs` bundles `src/desktop/*.jsx` into `dist/desktop/app.js`.
|
|
63
|
+
The UI runs as one concatenated script sharing global scope with React (UMD) —
|
|
64
|
+
no CDN, no in-browser Babel.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Carrier bootstrap fleet, mirrored from decentlan's shipped defaults
|
|
2
|
+
# (US-verified hosts first — the SDK dials the first 3 in order, so
|
|
3
|
+
# ordering decides which relays the whole fleet meets on).
|
|
4
|
+
# Refresh from https://beagle.chat/assets/bgservers.json
|
|
5
|
+
bootstrapNodes:
|
|
6
|
+
- host: 13.58.208.50
|
|
7
|
+
port: 33445
|
|
8
|
+
pk: 89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh
|
|
9
|
+
- host: 144.202.113.167
|
|
10
|
+
port: 33445
|
|
11
|
+
pk: EfT4YMq6qfHdDsCiBCgsEmA78E2NxVYVKVUS9bD6w9GH
|
|
12
|
+
- host: 149.28.98.141
|
|
13
|
+
port: 33445
|
|
14
|
+
pk: 81PPfuEyzSovgxymxr2ifiCWXdB5CGaiofx5MrxnhYV7
|
|
15
|
+
- host: 18.216.6.197
|
|
16
|
+
port: 33445
|
|
17
|
+
pk: H8sqhRrQuJZ6iLtP2wanxt4LzdNrN2NNFnpPdq1uJ9n2
|
|
18
|
+
- host: 52.57.248.163
|
|
19
|
+
port: 33445
|
|
20
|
+
pk: CfJLve8FNQPQJ9xYQ8oEVkPxeAPCN7iSdhnYFkWmWgLn
|
|
21
|
+
- host: 35.179.41.220
|
|
22
|
+
port: 33445
|
|
23
|
+
pk: 6u2vKadPa9wqDf531QaZy7FJN3c7Wzntm7dKXxXqvyNB
|
|
24
|
+
- host: 52.63.19.190
|
|
25
|
+
port: 33445
|
|
26
|
+
pk: EeNenbyS4sx3qtu82esT1V1NMe9dZib5LyQmYGM6fboK
|
|
27
|
+
- host: 47.100.103.201
|
|
28
|
+
port: 33445
|
|
29
|
+
pk: CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd
|
|
30
|
+
- host: 139.129.193.117
|
|
31
|
+
port: 33445
|
|
32
|
+
pk: 67UQhssARwMky1YgFA2oDGZ1RiQYQmS6JqWFHxtopKq5
|
|
33
|
+
- host: 18.216.102.47
|
|
34
|
+
port: 33445
|
|
35
|
+
pk: G5z8MqiNDFTadFUPfMdYsYtkUDbX5mNCMVHMZtsCnFeb
|
|
36
|
+
- host: 54.193.141.205
|
|
37
|
+
port: 33445
|
|
38
|
+
pk: 7TfZWZNV8vnBxxWzJXuvKgX2QyKkLpg2oXx3LQ5tg8LW
|
|
39
|
+
- host: 154.64.235.176
|
|
40
|
+
port: 33445
|
|
41
|
+
pk: GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL
|
|
42
|
+
- host: 52.74.215.181
|
|
43
|
+
port: 33445
|
|
44
|
+
pk: Xv6d34WaUw9bPn7YihzVAFw7D2igbQJZ3jwmzzfYVFV
|
|
45
|
+
- host: 52.83.171.135
|
|
46
|
+
port: 443
|
|
47
|
+
pk: 5tuHgK1Q4CYf4K5PutsEPK5E3Z7cbtEBdx7LwmdzqXHL
|
|
48
|
+
- host: 52.83.191.228
|
|
49
|
+
port: 33445
|
|
50
|
+
pk: 3khtxZo89SBScAMaHhTvD68pPHiKxgZT6hTCSZZVgNEm
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Official exit / infrastructure nodes shipped with @decentnetwork/lan.
|
|
2
|
+
#
|
|
3
|
+
# A fresh install auto-friends ONLY the dora registries (config/default-doras.yaml)
|
|
4
|
+
# and the exits listed here — NOT every machine that happens to be in the dora
|
|
5
|
+
# roster. This keeps a new client's friend list to infrastructure (doras + exits)
|
|
6
|
+
# instead of filling up with other people's personal/compute boxes.
|
|
7
|
+
#
|
|
8
|
+
# Each exit carries a `region`, so `agentnet proxy router` (with no routes.yaml)
|
|
9
|
+
# auto-builds the routing table: China sites → china exits, Japan/Binance →
|
|
10
|
+
# japan exit, Google/YouTube/GitHub/etc → us exits, everything else direct.
|
|
11
|
+
#
|
|
12
|
+
# Mechanism: these userids (plus the dora userids) become the default
|
|
13
|
+
# `dora.autoFriend` whitelist for a freshly-initialised config. The client
|
|
14
|
+
# still LEARNS every roster entry into IPAM (so names/IPs resolve), but only
|
|
15
|
+
# proactively friends infrastructure. Exits still serve arbitrary clients
|
|
16
|
+
# because the daemon auto-ACCEPTS incoming friend-requests — so hub-and-spoke
|
|
17
|
+
# works without meshing every personal machine.
|
|
18
|
+
#
|
|
19
|
+
# To run a full mesh instead (friend everyone in the roster):
|
|
20
|
+
# agentnet dora autofriend all
|
|
21
|
+
# To add/replace an exit: edit this file (data, not code) and republish, or
|
|
22
|
+
# locally: agentnet dora autofriend allow <name|userid> ...
|
|
23
|
+
#
|
|
24
|
+
# `region` is one of: china | japan | us (matches the router's built-in
|
|
25
|
+
# domain lists; see BUILTIN_REGION_DOMAINS in src/proxy/multi-exit-router.ts).
|
|
26
|
+
exits:
|
|
27
|
+
# --- China (China-only / GFW-blocked-from-outside sites) ---
|
|
28
|
+
- name: cn
|
|
29
|
+
userid: 5Aj6uQMd1cNRb9cGT4AwHCN4RdVZjfaGLtgKGhdP1LzN
|
|
30
|
+
virtual_ip: 10.86.1.15
|
|
31
|
+
region: china
|
|
32
|
+
- name: sh
|
|
33
|
+
userid: 6D1PLSVqbSpcxnDMAd8ZXLWycYveeP2hU4g3igDQEqA7
|
|
34
|
+
virtual_ip: 10.86.1.16
|
|
35
|
+
region: china
|
|
36
|
+
- name: callpass
|
|
37
|
+
userid: DZN2L9RV1YkHjqGHMTA6juZhskKA65AD2RyPt4umZVc7
|
|
38
|
+
virtual_ip: 10.86.1.17
|
|
39
|
+
region: china
|
|
40
|
+
# --- Japan (host "lico", dora name node-91) — Binance routes here ---
|
|
41
|
+
- name: tokyo
|
|
42
|
+
userid: 4uNnLnAQHVJ7td657MDaz4UnEuhnTW9sRAPSVeAEEQe4
|
|
43
|
+
virtual_ip: 10.86.68.90
|
|
44
|
+
region: japan
|
|
45
|
+
# --- US (Google, YouTube, GitHub, X/Twitter, LinkedIn, OpenAI) ---
|
|
46
|
+
- name: gojipower
|
|
47
|
+
userid: 2wErj1XreXt1UchE3FGhuvkZ4GoBpo8JGMn8X49nm2ec
|
|
48
|
+
virtual_ip: 10.86.166.16
|
|
49
|
+
region: us
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
export interface InlineFileEvent {
|
|
3
|
+
pubkey: string;
|
|
4
|
+
name: string;
|
|
5
|
+
fileType?: string;
|
|
6
|
+
data: Uint8Array;
|
|
7
|
+
via: "online" | "offline";
|
|
8
|
+
}
|
|
9
|
+
export interface InviteEvent {
|
|
10
|
+
pubkey: string;
|
|
11
|
+
ext?: string;
|
|
12
|
+
bundle?: string;
|
|
13
|
+
data: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
export interface FileOffer {
|
|
16
|
+
friendId: string;
|
|
17
|
+
fileNumber: number;
|
|
18
|
+
fileId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
size: number;
|
|
21
|
+
kind: number;
|
|
22
|
+
}
|
|
23
|
+
export interface FileProgress {
|
|
24
|
+
friendId: string;
|
|
25
|
+
fileId: string;
|
|
26
|
+
received: number;
|
|
27
|
+
total: number;
|
|
28
|
+
/** True when this is OUR outgoing transfer, false/absent when receiving. */
|
|
29
|
+
sending?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface FileComplete {
|
|
32
|
+
friendId: string;
|
|
33
|
+
fileId: string;
|
|
34
|
+
name: string;
|
|
35
|
+
size: number;
|
|
36
|
+
/** Present on the receiving side only. */
|
|
37
|
+
data?: Uint8Array;
|
|
38
|
+
sending?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface FileCancel {
|
|
41
|
+
friendId: string;
|
|
42
|
+
fileId: string;
|
|
43
|
+
sending: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface CarrierNodeOptions {
|
|
46
|
+
keyFile: string;
|
|
47
|
+
bootstrapNodes: {
|
|
48
|
+
host: string;
|
|
49
|
+
port: number;
|
|
50
|
+
pk: string;
|
|
51
|
+
}[];
|
|
52
|
+
expressNodes?: {
|
|
53
|
+
host: string;
|
|
54
|
+
port: number;
|
|
55
|
+
pk: string;
|
|
56
|
+
tls?: boolean;
|
|
57
|
+
}[];
|
|
58
|
+
nickname?: string;
|
|
59
|
+
statusMessage?: string;
|
|
60
|
+
fileResumeDir?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface FriendView {
|
|
63
|
+
pubkey: string;
|
|
64
|
+
carrierId: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
status: "online" | "offline" | "requested";
|
|
67
|
+
address?: string;
|
|
68
|
+
acceptedAt?: number;
|
|
69
|
+
requestedAt?: number;
|
|
70
|
+
}
|
|
71
|
+
export declare class CarrierNode extends EventEmitter {
|
|
72
|
+
#private;
|
|
73
|
+
create(opts: CarrierNodeOptions): Promise<void>;
|
|
74
|
+
start(): Promise<void>;
|
|
75
|
+
/** Join the DHT and publish ourselves so peers can find us. Deliberately
|
|
76
|
+
* fault-tolerant: a transient bootstrap timeout must not stop the app from
|
|
77
|
+
* starting — friends on a relay path still reach us, and the retry loop
|
|
78
|
+
* keeps trying in the background. */
|
|
79
|
+
join(): Promise<void>;
|
|
80
|
+
identity(): {
|
|
81
|
+
pubkey: string;
|
|
82
|
+
userid: string;
|
|
83
|
+
address: string;
|
|
84
|
+
};
|
|
85
|
+
friends(): FriendView[];
|
|
86
|
+
isFriendOnline(userid: string): boolean;
|
|
87
|
+
sessionStatus(pubkey: string): unknown;
|
|
88
|
+
dhtHealth(): unknown;
|
|
89
|
+
sendText(userid: string, text: string): Promise<void>;
|
|
90
|
+
sendFriendRequest(address: string, hello?: string): Promise<void>;
|
|
91
|
+
acceptFriendRequest(pubkey: string): Promise<void>;
|
|
92
|
+
removeFriend(userid: string): boolean;
|
|
93
|
+
setUserInfo(info: {
|
|
94
|
+
name?: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
}): void;
|
|
97
|
+
sign(message: Uint8Array): Uint8Array;
|
|
98
|
+
sendFile(userid: string, data: Uint8Array, name: string): string | null;
|
|
99
|
+
cancelSend(userid: string, fileId: string): boolean;
|
|
100
|
+
acceptFile(userid: string, fileNumber: number): void;
|
|
101
|
+
sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
|
|
102
|
+
sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
|
|
103
|
+
/** Native (iOS/Android/C) friends can't see toxcore file transfer; they only
|
|
104
|
+
* receive the inline FileModel envelope. decentlan detects this from the DHT
|
|
105
|
+
* key signature — an internal we don't have here, so beagle probes the
|
|
106
|
+
* capability and falls back rather than guessing wrong. */
|
|
107
|
+
stop(): Promise<void>;
|
|
108
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// CarrierNode — a messaging-only wrapper around @decentnetwork/peer.
|
|
2
|
+
//
|
|
3
|
+
// This is the deliberate opposite of decentlan's PeerManager (785 lines): that
|
|
4
|
+
// one also owns packet sessions, frame codecs, the IP/dora/rate custom-packet
|
|
5
|
+
// channels and the TUN plumbing. Beagle needs none of it. What a chat app
|
|
6
|
+
// actually needs from Carrier is: an identity, a friend list, text, files,
|
|
7
|
+
// and the invite channel that carries call signaling.
|
|
8
|
+
//
|
|
9
|
+
// Everything here runs at ordinary user privilege. Nothing opens a TUN, so
|
|
10
|
+
// nothing needs root — which is the entire point of the embedded backend.
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { Logger } from "./logger.js";
|
|
13
|
+
export class CarrierNode extends EventEmitter {
|
|
14
|
+
#peer;
|
|
15
|
+
#logger = new Logger({ prefix: "Carrier" });
|
|
16
|
+
#identity;
|
|
17
|
+
async create(opts) {
|
|
18
|
+
const mod = (await import("@decentnetwork/peer"));
|
|
19
|
+
this.#peer = await mod.Peer.create({
|
|
20
|
+
keyFile: opts.keyFile,
|
|
21
|
+
// "legacy" is what every other node on this network speaks — a beagle
|
|
22
|
+
// node that negotiated anything else would simply be invisible to the
|
|
23
|
+
// iOS/Android apps and to decentlan.
|
|
24
|
+
compatibilityMode: "legacy",
|
|
25
|
+
bootstrapNodes: opts.bootstrapNodes,
|
|
26
|
+
expressNodes: opts.expressNodes,
|
|
27
|
+
nickname: opts.nickname,
|
|
28
|
+
statusMessage: opts.statusMessage,
|
|
29
|
+
fileResumeDir: opts.fileResumeDir,
|
|
30
|
+
// NOTE: no bulkDataPacketId. decentlan registers the IP channel (163) as
|
|
31
|
+
// the SDK's bulk stream; beagle has no IP channel, so the SDK keeps its
|
|
32
|
+
// default and file transfer is unaffected.
|
|
33
|
+
});
|
|
34
|
+
this.#wireEvents();
|
|
35
|
+
}
|
|
36
|
+
async start() {
|
|
37
|
+
const peer = this.#require();
|
|
38
|
+
await peer.start();
|
|
39
|
+
// Identity only exists after start().
|
|
40
|
+
this.#identity = { pubkey: peer.pubkey(), userid: peer.userid(), address: peer.address() };
|
|
41
|
+
this.#logger.info(`identity ${this.#identity.address}`);
|
|
42
|
+
}
|
|
43
|
+
/** Join the DHT and publish ourselves so peers can find us. Deliberately
|
|
44
|
+
* fault-tolerant: a transient bootstrap timeout must not stop the app from
|
|
45
|
+
* starting — friends on a relay path still reach us, and the retry loop
|
|
46
|
+
* keeps trying in the background. */
|
|
47
|
+
async join() {
|
|
48
|
+
const peer = this.#require();
|
|
49
|
+
try {
|
|
50
|
+
await peer.joinNetwork();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
this.#logger.warn(`joinNetwork failed (${error.message}) — retrying in background`);
|
|
54
|
+
void this.#retryJoin();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
void this.#announce();
|
|
58
|
+
}
|
|
59
|
+
async #retryJoin() {
|
|
60
|
+
const peer = this.#require();
|
|
61
|
+
for (let attempt = 1; attempt <= 60; attempt++) {
|
|
62
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
63
|
+
try {
|
|
64
|
+
await peer.joinNetwork();
|
|
65
|
+
this.#logger.info(`joined after ${attempt} retries`);
|
|
66
|
+
void this.#announce();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// keep trying
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async #announce() {
|
|
75
|
+
const peer = this.#require();
|
|
76
|
+
for (let i = 0; i < 3; i++) {
|
|
77
|
+
try {
|
|
78
|
+
const stored = await peer.announceSelf(45_000);
|
|
79
|
+
if (stored?.length)
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// announce is best-effort; relay paths still work without it
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Stay findable: re-announce on the same cadence peerd uses.
|
|
87
|
+
setInterval(() => {
|
|
88
|
+
void peer.announceSelf(20_000).catch(() => undefined);
|
|
89
|
+
}, 120_000).unref?.();
|
|
90
|
+
}
|
|
91
|
+
#require() {
|
|
92
|
+
if (!this.#peer)
|
|
93
|
+
throw new Error("CarrierNode not created");
|
|
94
|
+
return this.#peer;
|
|
95
|
+
}
|
|
96
|
+
#wireEvents() {
|
|
97
|
+
const peer = this.#require();
|
|
98
|
+
peer.onText((m) => {
|
|
99
|
+
// Empty texts are session keepalives, not messages.
|
|
100
|
+
if (!m.text.length)
|
|
101
|
+
return;
|
|
102
|
+
this.emit("message", m.pubkey, m.text, m.via);
|
|
103
|
+
});
|
|
104
|
+
peer.onFriendRequest((r) => this.emit("friend-request", r));
|
|
105
|
+
peer.onFriendConnection?.((e) => this.emit("friend-connection", e));
|
|
106
|
+
peer.onInlineFile?.((f) => this.emit("inline-file", f));
|
|
107
|
+
peer.onInvite?.((evt) => {
|
|
108
|
+
// Only the "carrier" extension carries call signaling; other extensions
|
|
109
|
+
// belong to other apps and must not be handed to the call engine.
|
|
110
|
+
if (evt.ext !== undefined && evt.ext !== "carrier")
|
|
111
|
+
return;
|
|
112
|
+
this.emit("call-signal", { pubkey: evt.pubkey, data: evt.data });
|
|
113
|
+
});
|
|
114
|
+
// Forwarded verbatim — the embedded host keys off friendId/fileId/sending.
|
|
115
|
+
peer.onFile?.((o) => this.emit("file-offer", o));
|
|
116
|
+
peer.onFileProgress?.((p) => this.emit("file-progress", p));
|
|
117
|
+
peer.onFileComplete?.((p) => this.emit("file-complete", p));
|
|
118
|
+
peer.onFileCancel?.((p) => this.emit("file-cancel", p));
|
|
119
|
+
}
|
|
120
|
+
identity() {
|
|
121
|
+
if (!this.#identity)
|
|
122
|
+
throw new Error("CarrierNode not started");
|
|
123
|
+
return this.#identity;
|
|
124
|
+
}
|
|
125
|
+
friends() {
|
|
126
|
+
return this.#require().friends().map((f) => ({
|
|
127
|
+
pubkey: f.pubkey,
|
|
128
|
+
carrierId: f.userid || f.pubkey,
|
|
129
|
+
name: f.name,
|
|
130
|
+
// Keep "requested" distinct from "offline": it answers "did my friend
|
|
131
|
+
// request get through?", which collapsing would hide.
|
|
132
|
+
status: f.status === "online" ? "online" : f.status === "requested" ? "requested" : "offline",
|
|
133
|
+
address: f.address,
|
|
134
|
+
acceptedAt: f.acceptedAt,
|
|
135
|
+
requestedAt: f.requestedAt,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
isFriendOnline(userid) {
|
|
139
|
+
return this.friends().some((f) => f.carrierId === userid && f.status === "online");
|
|
140
|
+
}
|
|
141
|
+
sessionStatus(pubkey) {
|
|
142
|
+
try {
|
|
143
|
+
return this.#require().sessionStatus(pubkey);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
dhtHealth() {
|
|
150
|
+
try {
|
|
151
|
+
return this.#require().dhtHealth();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
sendText(userid, text) {
|
|
158
|
+
return this.#require().sendText(userid, text);
|
|
159
|
+
}
|
|
160
|
+
sendFriendRequest(address, hello) {
|
|
161
|
+
return this.#require().sendFriendRequest(address, hello);
|
|
162
|
+
}
|
|
163
|
+
acceptFriendRequest(pubkey) {
|
|
164
|
+
return this.#require().acceptFriendRequest(pubkey);
|
|
165
|
+
}
|
|
166
|
+
removeFriend(userid) {
|
|
167
|
+
return this.#require().removeFriend(userid);
|
|
168
|
+
}
|
|
169
|
+
setUserInfo(info) {
|
|
170
|
+
this.#require().setUserInfo(info);
|
|
171
|
+
}
|
|
172
|
+
sign(message) {
|
|
173
|
+
return this.#require().sign(message);
|
|
174
|
+
}
|
|
175
|
+
sendFile(userid, data, name) {
|
|
176
|
+
return this.#require().sendFile(userid, data, { name });
|
|
177
|
+
}
|
|
178
|
+
cancelSend(userid, fileId) {
|
|
179
|
+
// isSending=true: we are the sender. Without it the SDK looks for an
|
|
180
|
+
// inbound transfer with that id and finds nothing.
|
|
181
|
+
return this.#require().cancelFileById?.(userid, fileId, true) ?? false;
|
|
182
|
+
}
|
|
183
|
+
acceptFile(userid, fileNumber) {
|
|
184
|
+
this.#require().acceptFile(userid, fileNumber);
|
|
185
|
+
}
|
|
186
|
+
async sendInlineFile(userid, data, name) {
|
|
187
|
+
const peer = this.#require();
|
|
188
|
+
if (!peer.sendInlineFile)
|
|
189
|
+
throw new Error("This peer SDK build has no inline-file support");
|
|
190
|
+
await peer.sendInlineFile(userid, { name, data });
|
|
191
|
+
}
|
|
192
|
+
async sendCallSignal(userid, data) {
|
|
193
|
+
const peer = this.#require();
|
|
194
|
+
if (!peer.sendInvite)
|
|
195
|
+
throw new Error("This peer SDK build has no invite channel (calls unavailable)");
|
|
196
|
+
// ext "carrier" is the channel iOS/Android Beagle listen on for calls.
|
|
197
|
+
await peer.sendInvite(userid, data, { ext: "carrier" });
|
|
198
|
+
}
|
|
199
|
+
/** Native (iOS/Android/C) friends can't see toxcore file transfer; they only
|
|
200
|
+
* receive the inline FileModel envelope. decentlan detects this from the DHT
|
|
201
|
+
* key signature — an internal we don't have here, so beagle probes the
|
|
202
|
+
* capability and falls back rather than guessing wrong. */
|
|
203
|
+
async stop() {
|
|
204
|
+
if (!this.#peer)
|
|
205
|
+
return;
|
|
206
|
+
await this.#peer.stop().catch(() => undefined);
|
|
207
|
+
this.#peer = undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `beagle` — start the Beagle app: a local web UI for chat, files and calls.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately NOT a subcommand tree. Beagle is a consumer app; the whole CLI
|
|
5
|
+
// is "run it". Diagnostics and network administration stay in decentlan's
|
|
6
|
+
// `agentnet` CLI, which is the developer/operator tool.
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { startBeagleServer } from "./server.js";
|
|
12
|
+
import { openPeerHost, defaultConfigDir } from "./peer-host.js";
|
|
13
|
+
import { loadNodeConfig } from "./node-config.js";
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const get = (flag) => {
|
|
16
|
+
const i = argv.indexOf(flag);
|
|
17
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
18
|
+
};
|
|
19
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
20
|
+
console.log([
|
|
21
|
+
"beagle — P2P chat, files and calls over the Decent Network",
|
|
22
|
+
"",
|
|
23
|
+
"Usage: beagle [options]",
|
|
24
|
+
"",
|
|
25
|
+
" --port <n> HTTP port (default 8766)",
|
|
26
|
+
" --host <addr> bind address (default 127.0.0.1)",
|
|
27
|
+
" --config-dir <p> identity/config dir (default ~/.agentnet)",
|
|
28
|
+
" --dora-dir <p> dora roster dir, if this machine runs a dora",
|
|
29
|
+
" --backend <k> force 'daemon' or 'embedded' (default: auto)",
|
|
30
|
+
" -h, --help this text",
|
|
31
|
+
].join("\n"));
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
// 8766, not decentlan's 8765: during the transition both can run at once,
|
|
36
|
+
// so a user can compare old and new side by side without a port fight.
|
|
37
|
+
port: Number(get("--port") ?? process.env.BEAGLE_PORT ?? 8766),
|
|
38
|
+
host: get("--host") ?? "127.0.0.1",
|
|
39
|
+
configDir: get("--config-dir") ?? defaultConfigDir(),
|
|
40
|
+
doraDir: get("--dora-dir"),
|
|
41
|
+
backend: get("--backend"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const readJsonVer = (file) => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(file, "utf-8")).version ?? "";
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
async function main() {
|
|
53
|
+
const args = parseArgs(process.argv.slice(2));
|
|
54
|
+
// Bootstrap list: prefer the user's decentlan config so both stacks agree on
|
|
55
|
+
// the fleet, else fall back to the shipped defaults. A wrong list here is the
|
|
56
|
+
// difference between "joins the network" and "silently alone".
|
|
57
|
+
const { bootstrapNodes, expressNodes, nickname, statusMessage, autoAccept } = loadNodeConfig(args.configDir);
|
|
58
|
+
let peerHost;
|
|
59
|
+
let why = "";
|
|
60
|
+
try {
|
|
61
|
+
({ host: peerHost, why } = await openPeerHost({
|
|
62
|
+
configDir: args.configDir,
|
|
63
|
+
bootstrapNodes,
|
|
64
|
+
expressNodes,
|
|
65
|
+
nickname,
|
|
66
|
+
statusMessage,
|
|
67
|
+
autoAcceptFriends: autoAccept,
|
|
68
|
+
force: args.backend,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error(error.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
76
|
+
// Version lookup for the "my node" panel.
|
|
77
|
+
//
|
|
78
|
+
// NOT via createRequire().resolve(): peer and lan declare `exports` with an
|
|
79
|
+
// "import" condition only, so CJS resolution throws
|
|
80
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED for both the bare specifier and
|
|
81
|
+
// "<pkg>/package.json". (decentlan's own UI has the same call and has been
|
|
82
|
+
// silently showing an empty peer version because of it.) import.meta.resolve
|
|
83
|
+
// uses ESM resolution, which honours that condition.
|
|
84
|
+
const resolveVer = (pkg) => {
|
|
85
|
+
const manifestFrom = (start) => {
|
|
86
|
+
let dir = start;
|
|
87
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
88
|
+
const manifest = join(dir, "package.json");
|
|
89
|
+
if (existsSync(manifest)) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
92
|
+
if (parsed.name === pkg)
|
|
93
|
+
return parsed.version ?? "";
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// unreadable manifest — keep walking
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const up = dirname(dir);
|
|
100
|
+
if (up === dir)
|
|
101
|
+
break;
|
|
102
|
+
dir = up;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
return manifestFrom(dirname(fileURLToPath(import.meta.resolve(pkg)))) ?? "";
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Fall back to a plain node_modules lookup beside this package, which
|
|
111
|
+
// also covers the case where the dependency is absent entirely.
|
|
112
|
+
return manifestFrom(join(moduleDir, "..", "node_modules", ...pkg.split("/"))) ?? "";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
// If this machine also runs a dora, surface its allocation table.
|
|
116
|
+
const doraCandidates = args.doraDir
|
|
117
|
+
? [resolve(args.doraDir, "roster.yaml")]
|
|
118
|
+
: [resolve(homedir(), ".dora-test", "roster.yaml"), resolve(homedir(), ".decent-registry", "roster.yaml")];
|
|
119
|
+
const doraRosterPath = doraCandidates.find((p) => existsSync(p));
|
|
120
|
+
console.log(`beagle ${readJsonVer(join(moduleDir, "..", "package.json"))} — backend: ${peerHost.kind} (${why})`);
|
|
121
|
+
// The "open <url>" line is printed by the server itself, once the socket is
|
|
122
|
+
// actually bound. Printing it here first meant a port clash left a bogus
|
|
123
|
+
// invitation on screen directly above the error explaining it was wrong.
|
|
124
|
+
startBeagleServer({
|
|
125
|
+
call: (r) => peerHost.call(r),
|
|
126
|
+
routesPath: resolve(args.configDir, "routes.yaml"),
|
|
127
|
+
doraRosterPath,
|
|
128
|
+
downloadsDir: resolve(args.configDir, "downloads"),
|
|
129
|
+
meExtra: {
|
|
130
|
+
// The panel reads "lan <x> · peer <y>". Report the real decentlan version
|
|
131
|
+
// powering this backend, not beagle's own — conflating them made the
|
|
132
|
+
// node look like it was running lan 0.1.0.
|
|
133
|
+
lanVer: resolveVer("@decentnetwork/lan") || "(none)",
|
|
134
|
+
peerVer: resolveVer("@decentnetwork/peer"),
|
|
135
|
+
wire: "163",
|
|
136
|
+
channel: "@next",
|
|
137
|
+
},
|
|
138
|
+
listenHost: args.host,
|
|
139
|
+
listenPort: args.port,
|
|
140
|
+
});
|
|
141
|
+
const shutdown = async () => {
|
|
142
|
+
await peerHost.stop().catch(() => undefined);
|
|
143
|
+
process.exit(0);
|
|
144
|
+
};
|
|
145
|
+
process.on("SIGINT", shutdown);
|
|
146
|
+
process.on("SIGTERM", shutdown);
|
|
147
|
+
await new Promise(() => { }); // run until signalled
|
|
148
|
+
}
|
|
149
|
+
main().catch((error) => {
|
|
150
|
+
console.error(error);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|