@decentnetwork/lan 0.1.135 → 0.1.137
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/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/config/default-doras.yaml +0 -4
- package/config/default-exits.yaml +0 -6
- package/dist/cli/commands.d.ts +11 -0
- package/dist/cli/commands.js +43 -0
- package/dist/cli/index.js +6 -1
- package/dist/config/loader.js +0 -1
- package/dist/console/console.js +0 -2
- package/dist/daemon/ipc.d.ts +6 -1
- package/dist/daemon/ipc.js +5 -0
- package/dist/daemon/message-store.d.ts +22 -6
- package/dist/daemon/message-store.js +30 -3
- package/dist/daemon/server.d.ts +27 -0
- package/dist/daemon/server.js +226 -5
- package/dist/proxy/multi-exit-router.js +130 -1
- package/dist/tun/tun-device.js +23 -2
- package/dist/ui/desktop/app.js +66 -38
- package/dist/ui/server.js +21 -3
- package/docs/INSTALL.md +20 -6
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -17,10 +17,6 @@
|
|
|
17
17
|
# name — label only (logs / comments)
|
|
18
18
|
# segment — informational: the IP band this registry allocates from
|
|
19
19
|
doras:
|
|
20
|
-
- name: dora-mac
|
|
21
|
-
userid: 98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv
|
|
22
|
-
address: Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd
|
|
23
|
-
segment: 10.86.1.10-10.86.63.254
|
|
24
20
|
- name: dora-beagle
|
|
25
21
|
userid: AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN
|
|
26
22
|
address: NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM
|
|
@@ -47,9 +47,3 @@ exits:
|
|
|
47
47
|
userid: 2wErj1XreXt1UchE3FGhuvkZ4GoBpo8JGMn8X49nm2ec
|
|
48
48
|
virtual_ip: 10.86.166.16
|
|
49
49
|
region: us
|
|
50
|
-
# snoopy has NO public IP (LAN 10.0.0.115) — proves a NAT'd node can serve
|
|
51
|
-
# as an exit purely over the Carrier tunnel.
|
|
52
|
-
- name: snoopy
|
|
53
|
-
userid: BeMbuKf1poh3U4rjw3eeUAKZsbN2qBSjVZdPSvehmHsw
|
|
54
|
-
virtual_ip: 10.86.156.164
|
|
55
|
-
region: us
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -149,6 +149,17 @@ export declare function cmdFriendsAccept(args: {
|
|
|
149
149
|
userid: string;
|
|
150
150
|
configDir?: string;
|
|
151
151
|
}): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Show or set friends.autoAccept. With no `mode`, prints the current value.
|
|
154
|
+
* With on|off: if the daemon is up, toggles it live over IPC (no restart) and
|
|
155
|
+
* persists; if it's down, edits config.yaml so the next start picks it up.
|
|
156
|
+
* When OFF, incoming friend-requests queue for manual approval via
|
|
157
|
+
* `agentnet friends pending` (or the UI's Requests panel).
|
|
158
|
+
*/
|
|
159
|
+
export declare function cmdFriendsAutoAccept(args: {
|
|
160
|
+
mode?: string;
|
|
161
|
+
configDir?: string;
|
|
162
|
+
}): Promise<void>;
|
|
152
163
|
/**
|
|
153
164
|
* Drop a queued friend-request without accepting. Sender doesn't
|
|
154
165
|
* get a notification — they just see no acceptance.
|
package/dist/cli/commands.js
CHANGED
|
@@ -811,6 +811,49 @@ export async function cmdFriendsAccept(args) {
|
|
|
811
811
|
throw new Error(`Daemon refused: ${res.error}`);
|
|
812
812
|
console.log(`Accepted ${args.userid}.`);
|
|
813
813
|
}
|
|
814
|
+
/**
|
|
815
|
+
* Show or set friends.autoAccept. With no `mode`, prints the current value.
|
|
816
|
+
* With on|off: if the daemon is up, toggles it live over IPC (no restart) and
|
|
817
|
+
* persists; if it's down, edits config.yaml so the next start picks it up.
|
|
818
|
+
* When OFF, incoming friend-requests queue for manual approval via
|
|
819
|
+
* `agentnet friends pending` (or the UI's Requests panel).
|
|
820
|
+
*/
|
|
821
|
+
export async function cmdFriendsAutoAccept(args) {
|
|
822
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
823
|
+
const configPath = resolve(dir, "config.yaml");
|
|
824
|
+
const config = await ConfigLoader.load(configPath);
|
|
825
|
+
if (!args.mode) {
|
|
826
|
+
const cur = config.friends?.autoAccept ?? true;
|
|
827
|
+
console.log(`friends.autoAccept: ${cur ? "on" : "off"}`);
|
|
828
|
+
console.log(cur
|
|
829
|
+
? " Incoming friend-requests are accepted automatically."
|
|
830
|
+
: " Incoming friend-requests QUEUE for manual approval — 'agentnet friends pending' or the UI.");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const enabled = args.mode === "on";
|
|
834
|
+
let live = false;
|
|
835
|
+
if (daemonPid(config) !== null) {
|
|
836
|
+
// Try the live toggle (no session drop). An older daemon (started before
|
|
837
|
+
// this op existed) rejects it — fall back to persisting config so a restart
|
|
838
|
+
// applies it, rather than erroring out.
|
|
839
|
+
const res = await ipcCall(config, { op: "friends-autoaccept", enabled });
|
|
840
|
+
if (res.ok)
|
|
841
|
+
live = true;
|
|
842
|
+
}
|
|
843
|
+
if (!live) {
|
|
844
|
+
config.friends = { ...(config.friends ?? {}), autoAccept: enabled };
|
|
845
|
+
await ConfigLoader.save(config, configPath);
|
|
846
|
+
}
|
|
847
|
+
console.log(`friends.autoAccept set to ${enabled ? "on" : "off"}${live ? " (live)" : ""}.`);
|
|
848
|
+
if (!live) {
|
|
849
|
+
console.log("Saved to config.yaml — restart the daemon to apply (the running daemon predates the live toggle).");
|
|
850
|
+
}
|
|
851
|
+
if (!enabled) {
|
|
852
|
+
console.log("Incoming friend-requests will then queue — approve with:");
|
|
853
|
+
console.log(" agentnet friends pending # see who's waiting");
|
|
854
|
+
console.log(" agentnet friends accept <userid> # or use the UI Requests panel");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
814
857
|
/**
|
|
815
858
|
* Drop a queued friend-request without accepting. Sender doesn't
|
|
816
859
|
* get a notification — they just see no acceptance.
|
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
|
|
|
10
10
|
// Belt-and-braces — also raise it here in case the CLI is run directly
|
|
11
11
|
// (e.g. `node dist/cli/index.js` rather than via dist/index.js).
|
|
12
12
|
EventEmitter.defaultMaxListeners = 100;
|
|
13
|
-
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
13
|
+
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
14
14
|
async function main() {
|
|
15
15
|
await yargs(hideBin(process.argv))
|
|
16
16
|
.scriptName("agentnet")
|
|
@@ -270,6 +270,11 @@ async function main() {
|
|
|
270
270
|
.positional("alias", { type: "string", demandOption: true, describe: "Local display name" })
|
|
271
271
|
.option("config-dir", { type: "string" }), async (argv) => {
|
|
272
272
|
await cmdFriendAlias({ userid: argv.userid, alias: argv.alias, configDir: argv["config-dir"] });
|
|
273
|
+
})
|
|
274
|
+
.command("autoaccept [mode]", "Auto-accept incoming friend-requests on|off (off = queue for manual approve). No arg shows current.", (yy) => yy
|
|
275
|
+
.positional("mode", { type: "string", choices: ["on", "off"], describe: "on = auto-accept; off = queue for manual approval" })
|
|
276
|
+
.option("config-dir", { type: "string" }), async (argv) => {
|
|
277
|
+
await cmdFriendsAutoAccept({ mode: argv.mode, configDir: argv["config-dir"] });
|
|
273
278
|
})
|
|
274
279
|
.demandCommand(1, "Specify a friends subcommand (run 'agentnet friends --help')"), () => {
|
|
275
280
|
// parent handler — never invoked because demandCommand above
|
package/dist/config/loader.js
CHANGED
|
@@ -47,7 +47,6 @@ const DEFAULT_EXPRESS_NODES = [
|
|
|
47
47
|
{ host: "tokyo.fi.chat", port: 8443, pk: "EzpBtoUkjeMQfuLGWwTUbvzCn9rK4J648Ziy21EKxefo" },
|
|
48
48
|
];
|
|
49
49
|
const DEFAULT_DORAS_FALLBACK = [
|
|
50
|
-
{ name: "dora-mac", userid: "98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv", address: "Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd" }, // 10.86.1.10–63.254
|
|
51
50
|
{ name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM" }, // 10.86.64.10–127.254
|
|
52
51
|
{ name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK" }, // 10.86.128.10–191.254
|
|
53
52
|
{ name: "dora-tokyo", userid: "AB6BZfbrTFWw9eUoVpHdJqhhRnY8bTttp4CHTZ2Xfzxi", address: "MAW2eBqBuQ6SmaXTrnZRRayQjAj3aLatwPy4xmBp7spnJeV569op" }, // 10.86.192.10–254.254
|
package/dist/console/console.js
CHANGED
|
@@ -692,8 +692,6 @@ var DEFAULT_EXPRESS_NODES = [
|
|
|
692
692
|
{ host: "tokyo.fi.chat", port: 8443, pk: "EzpBtoUkjeMQfuLGWwTUbvzCn9rK4J648Ziy21EKxefo" }
|
|
693
693
|
];
|
|
694
694
|
var DEFAULT_DORAS_FALLBACK = [
|
|
695
|
-
{ name: "dora-mac", userid: "98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv", address: "Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd" },
|
|
696
|
-
// 10.86.1.10–63.254
|
|
697
695
|
{ name: "dora-beagle", userid: "AxKFEZFLDi23EmnJFNP6gjUM4CaNMPfWUvbFR9ixtMBN", address: "NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM" },
|
|
698
696
|
// 10.86.64.10–127.254
|
|
699
697
|
{ name: "dora-sh", userid: "GMEMLmCWLMBK6BJiMkbLPNkEjF4S2xRf1SqR9hM8fWV3", address: "ajg1ZMBw86UyujmEJzqKSCbi3wwEtg6tdGFTdESakyqujyxmqJZK" },
|
package/dist/daemon/ipc.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface IpcHandlers {
|
|
|
51
51
|
friendRemove: (userid: string) => Promise<void>;
|
|
52
52
|
/** Set/clear a local display alias for a friend. */
|
|
53
53
|
friendSetAlias: (userid: string, alias?: string) => Promise<void>;
|
|
54
|
+
/** Toggle whether incoming friend-requests are auto-accepted. Applies live
|
|
55
|
+
* (no restart) and persists to config.yaml. When off, requests queue to
|
|
56
|
+
* friends-pending for manual accept via `agentnet friends pending` / the UI. */
|
|
57
|
+
friendsAutoAccept: (enabled: boolean) => Promise<Record<string, unknown>>;
|
|
54
58
|
/** Update our own profile (display name + description); persists + re-pushes. */
|
|
55
59
|
setProfile: (info: {
|
|
56
60
|
name?: string;
|
|
@@ -90,7 +94,7 @@ export interface IpcHandlers {
|
|
|
90
94
|
selfRestart: () => Promise<Record<string, unknown>>;
|
|
91
95
|
}
|
|
92
96
|
export interface IpcRequest {
|
|
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";
|
|
97
|
+
op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "self-restart";
|
|
94
98
|
address?: string;
|
|
95
99
|
hello?: string;
|
|
96
100
|
userid?: string;
|
|
@@ -102,6 +106,7 @@ export interface IpcRequest {
|
|
|
102
106
|
before?: number;
|
|
103
107
|
limit?: number;
|
|
104
108
|
ts?: number;
|
|
109
|
+
enabled?: boolean;
|
|
105
110
|
}
|
|
106
111
|
export interface IpcResponseOk {
|
|
107
112
|
ok: true;
|
package/dist/daemon/ipc.js
CHANGED
|
@@ -204,6 +204,11 @@ export class IpcServer {
|
|
|
204
204
|
await this.handlers.friendSetAlias(req.userid, req.alias);
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
|
+
case "friends-autoaccept": {
|
|
208
|
+
if (typeof req.enabled !== "boolean")
|
|
209
|
+
throw new Error("enabled (boolean) is required");
|
|
210
|
+
return await this.handlers.friendsAutoAccept(req.enabled);
|
|
211
|
+
}
|
|
207
212
|
case "set-profile": {
|
|
208
213
|
await this.handlers.setProfile({ name: req.name, description: req.description });
|
|
209
214
|
return;
|
|
@@ -14,15 +14,21 @@ 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
|
+
/** Outgoing-text delivery state. "queued" means the peer was offline when the
|
|
18
|
+
* user hit send, so the daemon stored it and will deliver it (in order) the
|
|
19
|
+
* moment the friend reconnects. Cleared once actually sent. */
|
|
20
|
+
status?: "queued" | "sent" | "failed";
|
|
17
21
|
/** Present when this entry is a file transfer rather than a text message.
|
|
18
22
|
* `name`/`size` describe the file; received files (dir:"in") are saved to
|
|
19
23
|
* <configDir>/downloads/<name> and downloadable via the UI. For outgoing
|
|
20
24
|
* files, `status` tracks delivery and `sent` is the acked byte count (live
|
|
21
|
-
* progress) — the receiver confirms every byte before status becomes "sent".
|
|
25
|
+
* progress) — the receiver confirms every byte before status becomes "sent".
|
|
26
|
+
* "queued" mirrors text: the peer was offline, so the bytes live in
|
|
27
|
+
* <configDir>/outbox/<id> until the friend reconnects. */
|
|
22
28
|
file?: {
|
|
23
29
|
name: string;
|
|
24
30
|
size: number;
|
|
25
|
-
status?: "sending" | "sent" | "failed";
|
|
31
|
+
status?: "queued" | "sending" | "sent" | "failed";
|
|
26
32
|
sent?: number;
|
|
27
33
|
};
|
|
28
34
|
}
|
|
@@ -35,19 +41,29 @@ export declare class MessageStore {
|
|
|
35
41
|
private seq;
|
|
36
42
|
constructor(path: string);
|
|
37
43
|
private load;
|
|
38
|
-
/** Append a message and schedule a flush. Returns the stored message.
|
|
39
|
-
|
|
44
|
+
/** Append a message and schedule a flush. Returns the stored message.
|
|
45
|
+
* Pass `status: "queued"` for an outgoing text the peer wasn't online to
|
|
46
|
+
* receive — the daemon flushes it on reconnect. */
|
|
47
|
+
append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "queued" | "sent" | "failed"): ChatMessage;
|
|
48
|
+
/** Set (or, with undefined, clear) the delivery status on a text message.
|
|
49
|
+
* Used to flip a "queued" message to delivered once it's actually sent.
|
|
50
|
+
* No-op if the id isn't found or is a file entry. Returns true if patched. */
|
|
51
|
+
setStatus(peer: string, id: string, status?: "queued" | "sent" | "failed"): boolean;
|
|
52
|
+
/** Outgoing messages still awaiting delivery (peer was offline), oldest
|
|
53
|
+
* first — both queued text and queued file chips. The daemon drains this
|
|
54
|
+
* on a friend's reconnect. */
|
|
55
|
+
queuedOutgoing(peer: string): ChatMessage[];
|
|
40
56
|
/** Append a file-transfer entry (shown as a file chip in the UI). */
|
|
41
57
|
appendFile(peer: string, dir: "in" | "out", file: {
|
|
42
58
|
name: string;
|
|
43
59
|
size: number;
|
|
44
|
-
status?: "sending" | "sent" | "failed";
|
|
60
|
+
status?: "queued" | "sending" | "sent" | "failed";
|
|
45
61
|
sent?: number;
|
|
46
62
|
}, ts?: number): ChatMessage;
|
|
47
63
|
/** Patch an existing file message's transfer fields (status / sent bytes).
|
|
48
64
|
* No-op if the id isn't found. Returns true if it patched. */
|
|
49
65
|
patchFile(peer: string, id: string, patch: {
|
|
50
|
-
status?: "sending" | "sent" | "failed";
|
|
66
|
+
status?: "queued" | "sending" | "sent" | "failed";
|
|
51
67
|
sent?: number;
|
|
52
68
|
}): boolean;
|
|
53
69
|
private push;
|
|
@@ -43,9 +43,36 @@ export class MessageStore {
|
|
|
43
43
|
this.logger.warn(`Could not load ${this.path}: ${err}`);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
/** Append a message and schedule a flush. Returns the stored message.
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/** Append a message and schedule a flush. Returns the stored message.
|
|
47
|
+
* Pass `status: "queued"` for an outgoing text the peer wasn't online to
|
|
48
|
+
* receive — the daemon flushes it on reconnect. */
|
|
49
|
+
append(peer, dir, text, ts = Date.now(), status) {
|
|
50
|
+
const msg = { dir, text, ts, id: `${ts}-${this.seq++}` };
|
|
51
|
+
if (status)
|
|
52
|
+
msg.status = status;
|
|
53
|
+
return this.push(peer, msg);
|
|
54
|
+
}
|
|
55
|
+
/** Set (or, with undefined, clear) the delivery status on a text message.
|
|
56
|
+
* Used to flip a "queued" message to delivered once it's actually sent.
|
|
57
|
+
* No-op if the id isn't found or is a file entry. Returns true if patched. */
|
|
58
|
+
setStatus(peer, id, status) {
|
|
59
|
+
const arr = this.byPeer.get(peer);
|
|
60
|
+
const msg = arr?.find((m) => m.id === id);
|
|
61
|
+
if (!msg || msg.file)
|
|
62
|
+
return false;
|
|
63
|
+
if (status)
|
|
64
|
+
msg.status = status;
|
|
65
|
+
else
|
|
66
|
+
delete msg.status;
|
|
67
|
+
this.scheduleSave();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** Outgoing messages still awaiting delivery (peer was offline), oldest
|
|
71
|
+
* first — both queued text and queued file chips. The daemon drains this
|
|
72
|
+
* on a friend's reconnect. */
|
|
73
|
+
queuedOutgoing(peer) {
|
|
74
|
+
const arr = this.byPeer.get(peer) ?? [];
|
|
75
|
+
return arr.filter((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued"));
|
|
49
76
|
}
|
|
50
77
|
/** Append a file-transfer entry (shown as a file chip in the UI). */
|
|
51
78
|
appendFile(peer, dir, file, ts = Date.now()) {
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -44,8 +44,19 @@ export declare class DaemonServer {
|
|
|
44
44
|
/** Outgoing file transfers in flight: fileId → the chat message tracking it,
|
|
45
45
|
* so progress/complete/cancel events can patch its status + sent bytes. */
|
|
46
46
|
private readonly activeSends;
|
|
47
|
+
/** Directory holding queued (offline) outgoing file bytes: <configDir>/outbox.
|
|
48
|
+
* One file per queued message, named by its msgId. Set in start(). */
|
|
49
|
+
private outboxDir;
|
|
50
|
+
/** Peers whose outbox is currently being drained, so overlapping
|
|
51
|
+
* friend-connection "connected" events don't double-send. */
|
|
52
|
+
private readonly flushingOutbox;
|
|
53
|
+
/** Largest file we'll hold in the offline outbox. Live transfers handle any
|
|
54
|
+
* size; the queue is meant for small things the peer can grab on reconnect. */
|
|
55
|
+
private static readonly OUTBOX_MAX_FILE_BYTES;
|
|
47
56
|
private startedAt;
|
|
48
57
|
private isRunning;
|
|
58
|
+
private tunRebuildInProgress;
|
|
59
|
+
private lastTunRebuildMs;
|
|
49
60
|
private statusTimer?;
|
|
50
61
|
private pidFile?;
|
|
51
62
|
private configDir;
|
|
@@ -59,6 +70,12 @@ export declare class DaemonServer {
|
|
|
59
70
|
* del races the kernel (EBUSY) and the respawn leaves the daemon TUN-less.
|
|
60
71
|
*/
|
|
61
72
|
private reconfigureTunTo;
|
|
73
|
+
/** Watch a TUN device for an unexpected `closed` (the helper died — e.g. on
|
|
74
|
+
* WSL after "write tun: invalid argument") and rebuild it, so the data plane
|
|
75
|
+
* self-heals instead of staying down until a manual restart. Backoff-guarded
|
|
76
|
+
* so a helper that keeps dying immediately doesn't tight-loop. */
|
|
77
|
+
private attachTunRecovery;
|
|
78
|
+
private rebuildTun;
|
|
62
79
|
start(): Promise<void>;
|
|
63
80
|
stop(): Promise<void>;
|
|
64
81
|
/**
|
|
@@ -75,6 +92,16 @@ export declare class DaemonServer {
|
|
|
75
92
|
* restarts). Also ensures a friend-meta entry exists so the friend shows up
|
|
76
93
|
* in the UI list even before it's been renamed. */
|
|
77
94
|
private logChat;
|
|
95
|
+
/** On startup, re-queue any offline file transfer that was mid-flight when the
|
|
96
|
+
* daemon last stopped: an out chip stuck at "sending" whose bytes still sit in
|
|
97
|
+
* the outbox. Flips it back to "queued" so the next reconnect redelivers it
|
|
98
|
+
* (and the bytes aren't orphaned). */
|
|
99
|
+
private reconcileOutboxOnStart;
|
|
100
|
+
/** Deliver everything queued for `userid` while they were offline — queued
|
|
101
|
+
* text and queued files, oldest first. Called when a friend reconnects.
|
|
102
|
+
* Stops early if the link drops again, leaving the remainder queued for the
|
|
103
|
+
* next reconnect (true store-and-forward). Re-entrancy-guarded per peer. */
|
|
104
|
+
private flushOutbox;
|
|
78
105
|
/** Per-peer timestamp of the last self-heal friend-request re-send, so
|
|
79
106
|
* the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
|
|
80
107
|
private reFriendAt;
|
package/dist/daemon/server.js
CHANGED
|
@@ -39,6 +39,15 @@ function shellQuote(parts) {
|
|
|
39
39
|
* whitespace (e.g. macOS's U+202F narrow no-break space before "PM" in
|
|
40
40
|
* screenshot/recording names) to a plain space, so files don't need shell
|
|
41
41
|
* escaping. Never empty. */
|
|
42
|
+
/** True for image/video/audio by extension — the kinds the chat UI previews
|
|
43
|
+
* inline. Mirrors dkFileMediaKind() in the desktop UI. Used to decide whether
|
|
44
|
+
* to keep a local copy of an OUTGOING file so the sender can play it too. */
|
|
45
|
+
function isMediaFileName(name) {
|
|
46
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
47
|
+
return (["png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "bmp"].includes(ext) ||
|
|
48
|
+
["mp4", "mov", "webm", "m4v", "mkv", "avi"].includes(ext) ||
|
|
49
|
+
["mp3", "m4a", "aac", "wav", "ogg", "flac", "opus"].includes(ext));
|
|
50
|
+
}
|
|
42
51
|
function sanitizeFileName(name) {
|
|
43
52
|
const cleaned = (name || "file")
|
|
44
53
|
.replace(/[/\\]/g, "_")
|
|
@@ -80,8 +89,19 @@ export class DaemonServer {
|
|
|
80
89
|
/** Outgoing file transfers in flight: fileId → the chat message tracking it,
|
|
81
90
|
* so progress/complete/cancel events can patch its status + sent bytes. */
|
|
82
91
|
activeSends = new Map();
|
|
92
|
+
/** Directory holding queued (offline) outgoing file bytes: <configDir>/outbox.
|
|
93
|
+
* One file per queued message, named by its msgId. Set in start(). */
|
|
94
|
+
outboxDir = "";
|
|
95
|
+
/** Peers whose outbox is currently being drained, so overlapping
|
|
96
|
+
* friend-connection "connected" events don't double-send. */
|
|
97
|
+
flushingOutbox = new Set();
|
|
98
|
+
/** Largest file we'll hold in the offline outbox. Live transfers handle any
|
|
99
|
+
* size; the queue is meant for small things the peer can grab on reconnect. */
|
|
100
|
+
static OUTBOX_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
83
101
|
startedAt = 0;
|
|
84
102
|
isRunning = false;
|
|
103
|
+
tunRebuildInProgress = false;
|
|
104
|
+
lastTunRebuildMs = 0;
|
|
85
105
|
statusTimer;
|
|
86
106
|
pidFile;
|
|
87
107
|
configDir;
|
|
@@ -130,12 +150,56 @@ export class DaemonServer {
|
|
|
130
150
|
// Re-attach the packet-router's TUN listener — it was bound to the old
|
|
131
151
|
// TunDevice instance.
|
|
132
152
|
this.packetRouter?.swapTunDevice(this.tunDevice);
|
|
153
|
+
this.attachTunRecovery(this.tunDevice);
|
|
133
154
|
this.logger.info(`TUN now at ${newIp}`);
|
|
134
155
|
}
|
|
135
156
|
catch (err) {
|
|
136
157
|
this.logger.error(`Failed to reconfigure TUN to ${newIp}: ${err instanceof Error ? err.message : err}`);
|
|
137
158
|
}
|
|
138
159
|
}
|
|
160
|
+
/** Watch a TUN device for an unexpected `closed` (the helper died — e.g. on
|
|
161
|
+
* WSL after "write tun: invalid argument") and rebuild it, so the data plane
|
|
162
|
+
* self-heals instead of staying down until a manual restart. Backoff-guarded
|
|
163
|
+
* so a helper that keeps dying immediately doesn't tight-loop. */
|
|
164
|
+
attachTunRecovery(device) {
|
|
165
|
+
if (this.useMockTun)
|
|
166
|
+
return;
|
|
167
|
+
device.once("closed", () => {
|
|
168
|
+
if (!this.isRunning || this.useMockTun)
|
|
169
|
+
return;
|
|
170
|
+
const delay = Date.now() - this.lastTunRebuildMs < 10_000 ? 5_000 : 1_000;
|
|
171
|
+
this.logger.warn(`TUN closed unexpectedly — rebuilding data plane in ${delay}ms`);
|
|
172
|
+
setTimeout(() => void this.rebuildTun(), delay);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async rebuildTun() {
|
|
176
|
+
const rm = this.routeManager;
|
|
177
|
+
if (!this.isRunning || this.useMockTun || this.tunRebuildInProgress || !this.tunDevice || !rm)
|
|
178
|
+
return;
|
|
179
|
+
this.tunRebuildInProgress = true;
|
|
180
|
+
this.lastTunRebuildMs = Date.now();
|
|
181
|
+
try {
|
|
182
|
+
const ip = this.tunDevice.getConfig().ip;
|
|
183
|
+
const dev = new TunDevice({
|
|
184
|
+
config: { name: this.config.network.interface, ip, subnet: this.config.network.subnet },
|
|
185
|
+
mockMode: false,
|
|
186
|
+
});
|
|
187
|
+
await dev.open();
|
|
188
|
+
await rm.configureTun({ ...dev.getConfig(), name: dev.getInterface() });
|
|
189
|
+
this.tunDevice = dev;
|
|
190
|
+
this.packetRouter?.swapTunDevice(dev);
|
|
191
|
+
this.attachTunRecovery(dev);
|
|
192
|
+
this.logger.info(`TUN rebuilt at ${ip} (data plane recovered)`);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
this.logger.error(`TUN rebuild failed: ${err instanceof Error ? err.message : err} — retrying in 5s`);
|
|
196
|
+
this.tunRebuildInProgress = false;
|
|
197
|
+
if (this.isRunning)
|
|
198
|
+
setTimeout(() => void this.rebuildTun(), 5_000);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.tunRebuildInProgress = false;
|
|
202
|
+
}
|
|
139
203
|
async start() {
|
|
140
204
|
if (this.isRunning) {
|
|
141
205
|
throw new Error("Daemon already running");
|
|
@@ -225,6 +289,8 @@ export class DaemonServer {
|
|
|
225
289
|
// On-disk chat + friend-metadata stores (survive restarts; back the UI).
|
|
226
290
|
this.messageStore = new MessageStore(resolve(this.configDir, "messages.json"));
|
|
227
291
|
this.friendMeta = new FriendMetaStore(resolve(this.configDir, "friends-meta.json"));
|
|
292
|
+
this.outboxDir = resolve(this.configDir, "outbox");
|
|
293
|
+
this.reconcileOutboxOnStart();
|
|
228
294
|
// Start IPC as soon as the peer is up. The CLI uses it to drive
|
|
229
295
|
// operations that need the daemon's Carrier identity (e.g.
|
|
230
296
|
// friend-request) without spawning a competing Peer instance.
|
|
@@ -261,8 +327,23 @@ export class DaemonServer {
|
|
|
261
327
|
chatSend: async (userid, text) => {
|
|
262
328
|
if (!text)
|
|
263
329
|
return;
|
|
264
|
-
|
|
265
|
-
|
|
330
|
+
// If the friend is online, deliver immediately. If they're offline (or
|
|
331
|
+
// the live send throws), queue it: store as "queued" and flush on their
|
|
332
|
+
// next reconnect — so chat works store-and-forward, not just live.
|
|
333
|
+
if (this.peerManager?.isFriendOnline(userid)) {
|
|
334
|
+
try {
|
|
335
|
+
await this.peerManager.sendText(userid, text);
|
|
336
|
+
this.logChat(userid, "out", text);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
this.logger.warn(`sendText to ${userid.slice(0, 8)} failed, queuing: ${e.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this.messageStore?.append(userid, "out", text, Date.now(), "queued");
|
|
344
|
+
this.friendMeta?.ensure(userid);
|
|
345
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
346
|
+
this.logger.info(`Queued text for offline ${userid.slice(0, 8)} (delivers on reconnect)`);
|
|
266
347
|
},
|
|
267
348
|
chatHistory: async (peer, before, limit) => {
|
|
268
349
|
return { chats: this.messageStore?.history(peer, { before, limit }) ?? {} };
|
|
@@ -308,6 +389,16 @@ export class DaemonServer {
|
|
|
308
389
|
friendSetAlias: async (userid, alias) => {
|
|
309
390
|
this.friendMeta?.setAlias(userid, alias);
|
|
310
391
|
},
|
|
392
|
+
friendsAutoAccept: async (enabled) => {
|
|
393
|
+
// Live toggle: flip in-memory (the friend-request handler reads it
|
|
394
|
+
// fresh) AND persist to config.yaml so it survives a restart.
|
|
395
|
+
this.config.friends = { ...(this.config.friends ?? {}), autoAccept: enabled };
|
|
396
|
+
await ConfigLoader.save(this.config, resolve(this.configDir, "config.yaml")).catch((e) => {
|
|
397
|
+
this.logger.warn(`Failed to persist friends.autoAccept: ${e.message}`);
|
|
398
|
+
});
|
|
399
|
+
this.logger.info(`friends.autoAccept set to ${enabled}`);
|
|
400
|
+
return { autoAccept: enabled };
|
|
401
|
+
},
|
|
311
402
|
setProfile: async ({ name, description }) => {
|
|
312
403
|
// Re-push to friends over Carrier (live) …
|
|
313
404
|
this.peerManager?.setUserInfo({ name, description });
|
|
@@ -325,6 +416,42 @@ export class DaemonServer {
|
|
|
325
416
|
const { basename } = await import("path");
|
|
326
417
|
const data = await fs.readFile(path);
|
|
327
418
|
const name = basename(path);
|
|
419
|
+
// Keep a local copy of outgoing MEDIA under downloads/ so the sender
|
|
420
|
+
// can preview/play it in chat too (the UI uploads to a temp file that's
|
|
421
|
+
// deleted right after send; without this the sender has nothing to
|
|
422
|
+
// serve back). The receiver already saves every file the same way.
|
|
423
|
+
if (isMediaFileName(name)) {
|
|
424
|
+
void (async () => {
|
|
425
|
+
try {
|
|
426
|
+
const dir = resolve(this.configDir, "downloads");
|
|
427
|
+
await fs.mkdir(dir, { recursive: true });
|
|
428
|
+
await fs.writeFile(resolve(dir, sanitizeFileName(name)), data);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
this.logger.warn(`Could not cache sent media for preview: ${e.message}`);
|
|
432
|
+
}
|
|
433
|
+
})();
|
|
434
|
+
}
|
|
435
|
+
// Friend offline → queue the file for delivery on reconnect. Bytes are
|
|
436
|
+
// copied into <configDir>/outbox/<msgId> (survives restart). Capped at
|
|
437
|
+
// OUTBOX_MAX_FILE_BYTES so the queue stays small; bigger files should
|
|
438
|
+
// wait until the peer is online (the live transfer handles any size).
|
|
439
|
+
if (!this.peerManager?.isFriendOnline(userid)) {
|
|
440
|
+
if (data.length > DaemonServer.OUTBOX_MAX_FILE_BYTES) {
|
|
441
|
+
throw new Error(`Peer offline — queued files are limited to ${(DaemonServer.OUTBOX_MAX_FILE_BYTES / 1024 / 1024) | 0} MB ` +
|
|
442
|
+
`(this is ${(data.length / 1024 / 1024).toFixed(1)} MB). Send it once they're online.`);
|
|
443
|
+
}
|
|
444
|
+
const safe = sanitizeFileName(name);
|
|
445
|
+
const msg = this.messageStore?.appendFile(userid, "out", { name: safe, size: data.length, status: "queued", sent: 0 });
|
|
446
|
+
if (!msg)
|
|
447
|
+
throw new Error("Could not queue file (no message store)");
|
|
448
|
+
await fs.mkdir(this.outboxDir, { recursive: true });
|
|
449
|
+
await fs.writeFile(resolve(this.outboxDir, msg.id), data);
|
|
450
|
+
this.friendMeta?.ensure(userid);
|
|
451
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
452
|
+
this.logger.info(`Queued file "${name}" (${data.length}B) for offline ${userid.slice(0, 8)} (delivers on reconnect)`);
|
|
453
|
+
return { queued: true, name: safe, size: data.length };
|
|
454
|
+
}
|
|
328
455
|
const fileId = this.peerManager?.sendFile(userid, new Uint8Array(data), name);
|
|
329
456
|
if (!fileId)
|
|
330
457
|
throw new Error("No free transfer slot (or friend unknown)");
|
|
@@ -540,6 +667,7 @@ export class DaemonServer {
|
|
|
540
667
|
// In real mode: helper creates the TUN, then we configure IP + route on it.
|
|
541
668
|
// In mock mode: just open the mock device.
|
|
542
669
|
await this.tunDevice.open();
|
|
670
|
+
this.attachTunRecovery(this.tunDevice);
|
|
543
671
|
if (!this.useMockTun) {
|
|
544
672
|
// Use the actual device name from the helper (e.g. utun12 on macOS,
|
|
545
673
|
// agentnet0 on Linux) — different from the configured name.
|
|
@@ -578,7 +706,6 @@ export class DaemonServer {
|
|
|
578
706
|
// agentnet friends accept --userid <userid>
|
|
579
707
|
// agentnet friends reject --userid <userid>
|
|
580
708
|
// Pending requests survive daemon restarts.
|
|
581
|
-
const autoAcceptFriends = this.config.friends?.autoAccept ?? true;
|
|
582
709
|
const pendingPath = resolve(this.config.carrier.dataDir, "..", "pending-friends.json");
|
|
583
710
|
this.pendingFriends = new PendingFriendsStore(pendingPath);
|
|
584
711
|
const pubkeyHexToUserid = async (input) => {
|
|
@@ -605,6 +732,11 @@ export class DaemonServer {
|
|
|
605
732
|
// transitions immediately instead of on the next poll tick.
|
|
606
733
|
this.peerManager.on("friend-connection", (evt) => {
|
|
607
734
|
this.ipcEvents.emit("event", { type: "presence", userid: evt?.pubkey, status: evt?.status });
|
|
735
|
+
// A friend just came online — drain anything we queued for them while
|
|
736
|
+
// they were offline (text + small files), in order.
|
|
737
|
+
if (evt?.status === "connected" && evt.pubkey) {
|
|
738
|
+
void this.flushOutbox(evt.pubkey).catch((e) => this.logger.warn(`Outbox flush for ${evt.pubkey.slice(0, 8)} failed: ${e.message}`));
|
|
739
|
+
}
|
|
608
740
|
});
|
|
609
741
|
// File transfer (toxcore-standard): auto-accept offers from friends and
|
|
610
742
|
// save completed files under <configDir>/downloads/.
|
|
@@ -629,7 +761,11 @@ export class DaemonServer {
|
|
|
629
761
|
this.logger.warn(`File send to ${p.friendId.slice(0, 8)} aborted (${p.reason ?? "cancelled"})`);
|
|
630
762
|
const t = p.fileId ? this.activeSends.get(p.fileId) : undefined;
|
|
631
763
|
if (t) {
|
|
632
|
-
this
|
|
764
|
+
// If this was a queued (offline) send and its bytes are still in the
|
|
765
|
+
// outbox, revert the chip to "queued" so it retries on the next
|
|
766
|
+
// reconnect instead of dying as "failed". Otherwise mark failed.
|
|
767
|
+
const requeue = !!this.outboxDir && existsSync(resolve(this.outboxDir, t.msgId));
|
|
768
|
+
this.messageStore?.patchFile(t.peer, t.msgId, { status: requeue ? "queued" : "failed", sent: 0 });
|
|
633
769
|
this.activeSends.delete(p.fileId);
|
|
634
770
|
this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
|
|
635
771
|
}
|
|
@@ -646,6 +782,11 @@ export class DaemonServer {
|
|
|
646
782
|
if (t) {
|
|
647
783
|
this.messageStore?.patchFile(t.peer, t.msgId, { status: "sent", sent: p.size });
|
|
648
784
|
this.activeSends.delete(p.fileId);
|
|
785
|
+
// If this was a queued (offline) send, its bytes lived in the
|
|
786
|
+
// outbox — now delivered, drop the copy.
|
|
787
|
+
if (this.outboxDir) {
|
|
788
|
+
void import("fs/promises").then((fs) => fs.unlink(resolve(this.outboxDir, t.msgId)).catch(() => undefined));
|
|
789
|
+
}
|
|
649
790
|
this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
|
|
650
791
|
}
|
|
651
792
|
}
|
|
@@ -669,7 +810,9 @@ export class DaemonServer {
|
|
|
669
810
|
const who = `${req.name || "(unnamed)"} ${userid}`;
|
|
670
811
|
const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
|
|
671
812
|
this.ipcEvents.emit("event", { type: "request", userid });
|
|
672
|
-
|
|
813
|
+
// Read the flag LIVE (not captured at start) so `agentnet friends
|
|
814
|
+
// autoaccept off` takes effect immediately, no daemon restart.
|
|
815
|
+
if (this.config.friends?.autoAccept ?? true) {
|
|
673
816
|
this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
|
|
674
817
|
this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
|
|
675
818
|
this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
|
|
@@ -901,6 +1044,84 @@ export class DaemonServer {
|
|
|
901
1044
|
this.friendMeta?.ensure(userid);
|
|
902
1045
|
this.ipcEvents.emit("event", { type: "chat", userid, dir });
|
|
903
1046
|
}
|
|
1047
|
+
/** On startup, re-queue any offline file transfer that was mid-flight when the
|
|
1048
|
+
* daemon last stopped: an out chip stuck at "sending" whose bytes still sit in
|
|
1049
|
+
* the outbox. Flips it back to "queued" so the next reconnect redelivers it
|
|
1050
|
+
* (and the bytes aren't orphaned). */
|
|
1051
|
+
reconcileOutboxOnStart() {
|
|
1052
|
+
if (!this.messageStore || !this.outboxDir)
|
|
1053
|
+
return;
|
|
1054
|
+
let requeued = 0;
|
|
1055
|
+
const all = this.messageStore.history();
|
|
1056
|
+
for (const [peer, msgs] of Object.entries(all)) {
|
|
1057
|
+
for (const m of msgs) {
|
|
1058
|
+
if (m.dir === "out" && m.file?.status === "sending" && existsSync(resolve(this.outboxDir, m.id))) {
|
|
1059
|
+
this.messageStore.patchFile(peer, m.id, { status: "queued", sent: 0 });
|
|
1060
|
+
requeued++;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (requeued)
|
|
1065
|
+
this.logger.info(`Re-queued ${requeued} interrupted offline file transfer(s)`);
|
|
1066
|
+
}
|
|
1067
|
+
/** Deliver everything queued for `userid` while they were offline — queued
|
|
1068
|
+
* text and queued files, oldest first. Called when a friend reconnects.
|
|
1069
|
+
* Stops early if the link drops again, leaving the remainder queued for the
|
|
1070
|
+
* next reconnect (true store-and-forward). Re-entrancy-guarded per peer. */
|
|
1071
|
+
async flushOutbox(userid) {
|
|
1072
|
+
if (this.flushingOutbox.has(userid))
|
|
1073
|
+
return;
|
|
1074
|
+
if (!this.messageStore || !this.peerManager)
|
|
1075
|
+
return;
|
|
1076
|
+
const queued = this.messageStore.queuedOutgoing(userid);
|
|
1077
|
+
if (!queued.length)
|
|
1078
|
+
return;
|
|
1079
|
+
this.flushingOutbox.add(userid);
|
|
1080
|
+
try {
|
|
1081
|
+
const fs = await import("fs/promises");
|
|
1082
|
+
this.logger.info(`Flushing ${queued.length} queued item(s) to ${userid.slice(0, 8)}`);
|
|
1083
|
+
for (const m of queued) {
|
|
1084
|
+
// Bail if the friend dropped mid-flush — keep the rest queued.
|
|
1085
|
+
if (!this.peerManager.isFriendOnline(userid))
|
|
1086
|
+
break;
|
|
1087
|
+
if (m.file) {
|
|
1088
|
+
const bytesPath = resolve(this.outboxDir, m.id);
|
|
1089
|
+
let data;
|
|
1090
|
+
try {
|
|
1091
|
+
data = await fs.readFile(bytesPath);
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
// Bytes vanished (manual cleanup / earlier delivery) — drop the chip.
|
|
1095
|
+
this.messageStore.patchFile(userid, m.id, { status: "failed" });
|
|
1096
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
const fileId = this.peerManager.sendFile(userid, new Uint8Array(data), m.file.name);
|
|
1100
|
+
if (!fileId)
|
|
1101
|
+
break; // no free transfer slot — retry on next reconnect
|
|
1102
|
+
// Hand off to the live progress/complete path: flip to "sending" and
|
|
1103
|
+
// track by fileId so file-progress/-complete patch this same chip.
|
|
1104
|
+
this.messageStore.patchFile(userid, m.id, { status: "sending", sent: 0 });
|
|
1105
|
+
this.activeSends.set(fileId, { peer: userid, msgId: m.id });
|
|
1106
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
try {
|
|
1110
|
+
await this.peerManager.sendText(userid, m.text);
|
|
1111
|
+
this.messageStore.setStatus(userid, m.id, undefined); // delivered
|
|
1112
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
1113
|
+
}
|
|
1114
|
+
catch (e) {
|
|
1115
|
+
this.logger.warn(`Flush sendText to ${userid.slice(0, 8)} failed: ${e.message}`);
|
|
1116
|
+
break; // transient — leave this and the rest queued
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
finally {
|
|
1122
|
+
this.flushingOutbox.delete(userid);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
904
1125
|
/** Per-peer timestamp of the last self-heal friend-request re-send, so
|
|
905
1126
|
* the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
|
|
906
1127
|
reFriendAt = new Map();
|
|
@@ -300,7 +300,136 @@ export function startMultiExitRouter(opts) {
|
|
|
300
300
|
client.on("error", () => up.destroy());
|
|
301
301
|
up.on("error", () => client.destroy());
|
|
302
302
|
}
|
|
303
|
-
|
|
303
|
+
// A destination is "local" when it should be dialed straight from this box on
|
|
304
|
+
// the normal routing table — loopback, RFC1918, the agentnet 10.86/16 vIPs
|
|
305
|
+
// (which ride the TUN), and *.decent/*.lan names. For these we must NOT bind
|
|
306
|
+
// the physical-egress source IP (that would break the TUN route to 10.86.x).
|
|
307
|
+
function isLocalDest(host) {
|
|
308
|
+
const h = host.toLowerCase();
|
|
309
|
+
if (h === "localhost" || h.endsWith(".localhost"))
|
|
310
|
+
return true;
|
|
311
|
+
if (h.endsWith(".lan") || h.endsWith(".decent") || h.endsWith(".agentnet") || h.endsWith(".local"))
|
|
312
|
+
return true;
|
|
313
|
+
if (h === "::1")
|
|
314
|
+
return true;
|
|
315
|
+
if (h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80"))
|
|
316
|
+
return true; // IPv6 ULA / link-local
|
|
317
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
|
|
318
|
+
const [a, b] = h.split(".").map(Number);
|
|
319
|
+
if (a === 127 || a === 10)
|
|
320
|
+
return true; // loopback / 10/8 (incl 10.86 agentnet)
|
|
321
|
+
if (a === 192 && b === 168)
|
|
322
|
+
return true; // 192.168/16
|
|
323
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
324
|
+
return true; // 172.16/12
|
|
325
|
+
if (a === 169 && b === 254)
|
|
326
|
+
return true; // link-local
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
// Plain-HTTP forwarding (the browser's proxy request for an `http://` URL,
|
|
331
|
+
// which arrives as a normal request with an absolute-form URL — NOT a CONNECT
|
|
332
|
+
// tunnel). Dial the origin directly from here and pipe the streams both ways.
|
|
333
|
+
function httpForwardDirect(host, port, path, req, res, headers) {
|
|
334
|
+
const bindIp = isLocalDest(host) ? undefined : opts.egressBindIp;
|
|
335
|
+
directTotal++;
|
|
336
|
+
const up = http.request({ host, port, path, method: req.method, headers, localAddress: bindIp }, (upRes) => {
|
|
337
|
+
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
338
|
+
upRes.pipe(res);
|
|
339
|
+
});
|
|
340
|
+
up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy(new Error("upstream timeout")));
|
|
341
|
+
up.on("error", (e) => { if (!res.headersSent)
|
|
342
|
+
res.writeHead(502, { "content-type": "text/plain" }); res.end(`direct proxy error: ${e.message}\n`); });
|
|
343
|
+
req.pipe(up);
|
|
344
|
+
}
|
|
345
|
+
// Plain-HTTP through an exit region: open a CONNECT tunnel to host:port via the
|
|
346
|
+
// exit, then ride the normal http.request over that raw socket. Fails over to
|
|
347
|
+
// the next exit if CONNECT is refused.
|
|
348
|
+
function httpForwardViaExit(order, idx, target, host, port, path, req, res, headers) {
|
|
349
|
+
if (idx >= order.length) {
|
|
350
|
+
if (!res.headersSent)
|
|
351
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
352
|
+
res.end("all exits failed\n");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const exit = order[idx];
|
|
356
|
+
const tun = net.connect(exit.port, exit.host);
|
|
357
|
+
tun.setTimeout(CONNECT_TIMEOUT_MS, () => tun.destroy());
|
|
358
|
+
tun.once("connect", () => { tun.setTimeout(0); tun.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`); });
|
|
359
|
+
let buf = Buffer.alloc(0);
|
|
360
|
+
const onHdr = (chunk) => {
|
|
361
|
+
buf = Buffer.concat([buf, chunk]);
|
|
362
|
+
const i = buf.indexOf("\r\n\r\n");
|
|
363
|
+
if (i === -1)
|
|
364
|
+
return;
|
|
365
|
+
tun.removeListener("data", onHdr);
|
|
366
|
+
const statusLine = buf.slice(0, i).toString("utf8").split("\r\n")[0] || "";
|
|
367
|
+
if (!/ 200/.test(statusLine)) {
|
|
368
|
+
tun.destroy();
|
|
369
|
+
httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
exit.lastSuccessMs = Date.now();
|
|
373
|
+
exit.fails = 0;
|
|
374
|
+
if (!exit.healthy)
|
|
375
|
+
exit.healthy = true;
|
|
376
|
+
const leftover = buf.slice(i + 4);
|
|
377
|
+
if (leftover.length)
|
|
378
|
+
tun.unshift(leftover);
|
|
379
|
+
const up = http.request({ host, port, path, method: req.method, headers, createConnection: () => tun }, (upRes) => {
|
|
380
|
+
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
381
|
+
upRes.pipe(res);
|
|
382
|
+
});
|
|
383
|
+
up.on("error", (e) => { if (!res.headersSent)
|
|
384
|
+
res.writeHead(502, { "content-type": "text/plain" }); res.end(`exit proxy error: ${e.message}\n`); });
|
|
385
|
+
req.pipe(up);
|
|
386
|
+
};
|
|
387
|
+
tun.on("data", onHdr);
|
|
388
|
+
tun.on("error", () => { tun.removeListener("data", onHdr); httpForwardViaExit(order, idx + 1, target, host, port, path, req, res, headers); });
|
|
389
|
+
}
|
|
390
|
+
const server = http.createServer((req, res) => {
|
|
391
|
+
// Browsers send proxy HTTP requests in absolute-form: `GET http://h:p/path`.
|
|
392
|
+
// Anything else is someone hitting the proxy port directly — show a hint.
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = new URL(req.url || "");
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
399
|
+
res.end(`agentnet multi-exit proxy [${mode}] on ${listenHost}:${listenPort}\nSet this as your browser's HTTP+HTTPS proxy; http:// and https:// both work.\n`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (parsed.protocol !== "http:") {
|
|
403
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
404
|
+
res.end("only http:// here; https:// uses CONNECT\n");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const host = parsed.hostname.toLowerCase();
|
|
408
|
+
const port = Number(parsed.port) || 80;
|
|
409
|
+
const path = (parsed.pathname || "/") + (parsed.search || "");
|
|
410
|
+
const target = `${host}:${port}`;
|
|
411
|
+
// Forward the client's headers minus hop-by-hop proxy bits.
|
|
412
|
+
const headers = { ...req.headers };
|
|
413
|
+
delete headers["proxy-connection"];
|
|
414
|
+
let region = routeFor(host);
|
|
415
|
+
if (region !== "direct" && !regionHasExits(region))
|
|
416
|
+
region = "direct";
|
|
417
|
+
if (!seenHosts.has(host)) {
|
|
418
|
+
seenHosts.add(host);
|
|
419
|
+
log(`route ${host} → ${region === "direct" ? "DIRECT" : `region [${region}]`} (http)`);
|
|
420
|
+
}
|
|
421
|
+
if (region === "direct") {
|
|
422
|
+
httpForwardDirect(host, port, path, req, res, headers);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const order = pickOrder(region);
|
|
426
|
+
if (order.length === 0) {
|
|
427
|
+
res.writeHead(503, { "content-type": "text/plain" });
|
|
428
|
+
res.end("no healthy exit for region\n");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
httpForwardViaExit(order, 0, target, host, port, path, req, res, headers);
|
|
432
|
+
});
|
|
304
433
|
server.on("connect", (req, client, head) => {
|
|
305
434
|
const target = req.url || "";
|
|
306
435
|
const host = (target.split(":")[0] || "").toLowerCase();
|
package/dist/tun/tun-device.js
CHANGED
|
@@ -97,8 +97,11 @@ export class TunDevice extends EventEmitter {
|
|
|
97
97
|
});
|
|
98
98
|
return;
|
|
99
99
|
}
|
|
100
|
-
// Real mode: framed write to helper stdin
|
|
101
|
-
|
|
100
|
+
// Real mode: framed write to helper stdin. Bail out cleanly if the pipe is
|
|
101
|
+
// gone or no longer writable — throwing here is caught by the router's
|
|
102
|
+
// try/catch (→ recordDrop "tun-write-failed"), whereas writing into a dead
|
|
103
|
+
// pipe would emit an async EPIPE 'error' event that crashes the daemon.
|
|
104
|
+
if (!this.helper || !this.helper.stdin || this.helper.stdin.destroyed || !this.helper.stdin.writable) {
|
|
102
105
|
throw new Error("Helper process not available");
|
|
103
106
|
}
|
|
104
107
|
const lenBuf = Buffer.alloc(4);
|
|
@@ -204,6 +207,24 @@ export class TunDevice extends EventEmitter {
|
|
|
204
207
|
this.emit("closed");
|
|
205
208
|
}
|
|
206
209
|
});
|
|
210
|
+
// Error handlers on the process AND its pipes. Without these, an EPIPE when
|
|
211
|
+
// the helper dies mid-write (observed on WSL: repeated "write tun: invalid
|
|
212
|
+
// argument" → helper exits → the next TUN write hits a broken stdin) is
|
|
213
|
+
// emitted as an UNHANDLED 'error' event on the stdin socket and crashes the
|
|
214
|
+
// entire daemon — taking the TUN and every session down. Catch them: log,
|
|
215
|
+
// and mark the device closed so the router stops writing (drops with
|
|
216
|
+
// "tun-down") and the reopen path can rebuild the TUN instead of crashing.
|
|
217
|
+
const onStreamError = (where) => (err) => {
|
|
218
|
+
this.logger.warn(`TUN helper ${where} error: ${err.message}`);
|
|
219
|
+
if (this.isOpen) {
|
|
220
|
+
this.isOpen = false;
|
|
221
|
+
this.emit("closed");
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
this.helper.on("error", onStreamError("process"));
|
|
225
|
+
this.helper.stdin.on("error", onStreamError("stdin"));
|
|
226
|
+
this.helper.stdout.on("error", onStreamError("stdout"));
|
|
227
|
+
this.helper.stderr.on("error", onStreamError("stderr"));
|
|
207
228
|
this.isOpen = true;
|
|
208
229
|
this.emit("ready");
|
|
209
230
|
}
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -285,7 +285,7 @@ function useDaemonData() {
|
|
|
285
285
|
status: m.file.status,
|
|
286
286
|
pct: m.file.status === "sending" && m.file.size ? Math.min(100, Math.floor((m.file.sent || 0) / m.file.size * 100)) : void 0
|
|
287
287
|
} : void 0,
|
|
288
|
-
status: m.dir === "out" ? "read" : void 0
|
|
288
|
+
status: m.dir === "out" ? m.status === "queued" ? "queued" : "read" : void 0
|
|
289
289
|
}));
|
|
290
290
|
const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
|
|
291
291
|
setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
|
|
@@ -1032,38 +1032,39 @@ const inputStyle = {
|
|
|
1032
1032
|
};
|
|
1033
1033
|
function Msg({ m, peer, T }) {
|
|
1034
1034
|
const mine = m.from === "me";
|
|
1035
|
-
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 ?
|
|
1036
|
-
// Received media
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1035
|
+
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 ? (
|
|
1036
|
+
// Inline preview/player. Received media: as soon as it's saved.
|
|
1037
|
+
// Sent media: once it's fully delivered ('sent') — the daemon keeps a
|
|
1038
|
+
// local copy so it plays on the sender's side too.
|
|
1039
|
+
(m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") && (mine ? m.file.status === "sent" : m.file.status !== "sending" && m.file.status !== "failed" && m.file.status !== "queued") ? (
|
|
1040
|
+
// Received media → inline preview / player, with name, size, download.
|
|
1041
|
+
/* @__PURE__ */ React.createElement("div", { style: {
|
|
1042
|
+
display: "flex",
|
|
1043
|
+
flexDirection: "column",
|
|
1044
|
+
gap: 6,
|
|
1045
|
+
maxWidth: 300,
|
|
1046
|
+
background: "var(--bub-them)",
|
|
1047
|
+
border: "1px solid var(--line)",
|
|
1048
|
+
borderRadius: 12,
|
|
1049
|
+
padding: 6
|
|
1050
|
+
} }, m.file.media === "image" && /* @__PURE__ */ React.createElement("a", { href: dkFileUrl(m.file.name), target: "_blank", rel: "noreferrer", style: { display: "block", lineHeight: 0 } }, /* @__PURE__ */ React.createElement(
|
|
1051
|
+
"img",
|
|
1052
|
+
{
|
|
1053
|
+
src: dkFileUrl(m.file.name),
|
|
1054
|
+
alt: m.file.name,
|
|
1055
|
+
style: { display: "block", maxWidth: "100%", maxHeight: 280, borderRadius: 8, objectFit: "cover" }
|
|
1056
|
+
}
|
|
1057
|
+
)), m.file.media === "video" && /* @__PURE__ */ React.createElement(
|
|
1058
|
+
"video",
|
|
1059
|
+
{
|
|
1060
|
+
src: dkFileUrl(m.file.name),
|
|
1061
|
+
controls: true,
|
|
1062
|
+
preload: "metadata",
|
|
1063
|
+
style: { maxWidth: "100%", maxHeight: 320, borderRadius: 8, background: "#000" }
|
|
1064
|
+
}
|
|
1065
|
+
), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
|
|
1066
|
+
) : (() => {
|
|
1067
|
+
const cardStyle = {
|
|
1067
1068
|
display: "flex",
|
|
1068
1069
|
alignItems: "center",
|
|
1069
1070
|
gap: 10,
|
|
@@ -1074,11 +1075,34 @@ function Msg({ m, peer, T }) {
|
|
|
1074
1075
|
minWidth: 200,
|
|
1075
1076
|
textDecoration: "none",
|
|
1076
1077
|
cursor: mine ? "default" : "pointer"
|
|
1078
|
+
};
|
|
1079
|
+
const icon = /* @__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.media === "image" ? "image" : m.file.media === "video" ? "video" : m.file.media === "audio" ? "play" : "file", size: 18, stroke: 1.9 }));
|
|
1080
|
+
const meta = /* @__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.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 sending ${m.file.pct != null ? m.file.pct + "%" : "\u2026"}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
|
|
1081
|
+
if (mine) {
|
|
1082
|
+
return /* @__PURE__ */ React.createElement("div", { style: cardStyle }, icon, meta, m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)" } }, m.file.pct != null ? m.file.pct + "%" : "\u2026") : m.file.status === "failed" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }));
|
|
1077
1083
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1084
|
+
return /* @__PURE__ */ React.createElement(
|
|
1085
|
+
"div",
|
|
1086
|
+
{
|
|
1087
|
+
style: cardStyle,
|
|
1088
|
+
title: "open",
|
|
1089
|
+
onClick: () => window.open(dkFileUrl(m.file.name), "_blank", "noopener")
|
|
1090
|
+
},
|
|
1091
|
+
icon,
|
|
1092
|
+
meta,
|
|
1093
|
+
/* @__PURE__ */ React.createElement(
|
|
1094
|
+
"a",
|
|
1095
|
+
{
|
|
1096
|
+
href: dkFileDownloadUrl(m.file.name),
|
|
1097
|
+
download: m.file.name,
|
|
1098
|
+
title: "download",
|
|
1099
|
+
onClick: (e) => e.stopPropagation(),
|
|
1100
|
+
style: { display: "inline-flex", flexShrink: 0 }
|
|
1101
|
+
},
|
|
1102
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
|
|
1103
|
+
)
|
|
1104
|
+
);
|
|
1105
|
+
})()
|
|
1082
1106
|
) : /* @__PURE__ */ React.createElement("div", { style: {
|
|
1083
1107
|
padding: "8px 12px",
|
|
1084
1108
|
borderRadius: 12,
|
|
@@ -1092,7 +1116,7 @@ function Msg({ m, peer, T }) {
|
|
|
1092
1116
|
lineHeight: 1.4,
|
|
1093
1117
|
letterSpacing: -0.1,
|
|
1094
1118
|
wordBreak: "break-word"
|
|
1095
|
-
} }, 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)" }))));
|
|
1119
|
+
} }, 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 === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" }))));
|
|
1096
1120
|
}
|
|
1097
1121
|
function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
|
|
1098
1122
|
const scrollRef = React.useRef(null);
|
|
@@ -1362,6 +1386,8 @@ const STR = {
|
|
|
1362
1386
|
message: "Message",
|
|
1363
1387
|
send: "Send",
|
|
1364
1388
|
pickPeer: "Select a peer to open the conversation",
|
|
1389
|
+
queued: "queued",
|
|
1390
|
+
open: "open",
|
|
1365
1391
|
myIp: "my ip",
|
|
1366
1392
|
copyAddr: "copy address",
|
|
1367
1393
|
peersOnline: "peers online",
|
|
@@ -1437,6 +1463,8 @@ const STR = {
|
|
|
1437
1463
|
message: "\u6D88\u606F",
|
|
1438
1464
|
send: "\u53D1\u9001",
|
|
1439
1465
|
pickPeer: "\u9009\u62E9\u4E00\u4F4D\u597D\u53CB\u5F00\u59CB\u4F1A\u8BDD",
|
|
1466
|
+
queued: "\u5F85\u53D1\u9001",
|
|
1467
|
+
open: "\u6253\u5F00",
|
|
1440
1468
|
myIp: "\u6211\u7684 IP",
|
|
1441
1469
|
copyAddr: "\u590D\u5236\u5730\u5740",
|
|
1442
1470
|
peersOnline: "\u5728\u7EBF\u597D\u53CB",
|
package/dist/ui/server.js
CHANGED
|
@@ -319,10 +319,28 @@ export function startFriendUi(opts) {
|
|
|
319
319
|
mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", wav: "audio/wav",
|
|
320
320
|
ogg: "audio/ogg", flac: "audio/flac", opus: "audio/opus",
|
|
321
321
|
pdf: "application/pdf",
|
|
322
|
+
// Text / markup / code / config → served as UTF-8 text so a plain
|
|
323
|
+
// click opens (previews) them in the browser instead of forcing a
|
|
324
|
+
// download. `.md` and friends are the common case here.
|
|
325
|
+
md: "text/markdown", markdown: "text/markdown",
|
|
326
|
+
txt: "text/plain", text: "text/plain", log: "text/plain",
|
|
327
|
+
json: "application/json", csv: "text/csv", tsv: "text/tab-separated-values",
|
|
328
|
+
xml: "text/xml", yaml: "text/plain", yml: "text/plain",
|
|
329
|
+
ini: "text/plain", conf: "text/plain", cfg: "text/plain", toml: "text/plain",
|
|
330
|
+
html: "text/html", htm: "text/html", css: "text/css",
|
|
331
|
+
js: "text/plain", ts: "text/plain", jsx: "text/plain", tsx: "text/plain",
|
|
332
|
+
py: "text/plain", sh: "text/plain", srt: "text/plain", vtt: "text/vtt",
|
|
322
333
|
};
|
|
323
334
|
const ctype = MIME[ext] || "application/octet-stream";
|
|
324
335
|
const isMedia = ctype.startsWith("image/") || ctype.startsWith("video/") || ctype.startsWith("audio/");
|
|
325
|
-
const
|
|
336
|
+
const isText = ctype.startsWith("text/") || ctype === "application/json" || ctype === "application/pdf";
|
|
337
|
+
// Anything we can render inline (media, text, pdf) opens on a plain GET;
|
|
338
|
+
// `?dl=1` forces a save. Binary stays attachment-only.
|
|
339
|
+
const inlineViewable = isMedia || isText;
|
|
340
|
+
const wantDownload = q.get("dl") === "1" || !inlineViewable;
|
|
341
|
+
// Text needs an explicit charset so the browser renders UTF-8 (Chinese
|
|
342
|
+
// markdown etc.) correctly rather than as mojibake.
|
|
343
|
+
const ctypeHeader = ctype.startsWith("text/") ? `${ctype}; charset=utf-8` : ctype;
|
|
326
344
|
// Content-Disposition must be ASCII — Node throws ERR_INVALID_CHAR on a
|
|
327
345
|
// non-ASCII header value (filenames from macOS contain U+202F before
|
|
328
346
|
// "PM", and others may be Chinese, etc.). Provide an ASCII-only
|
|
@@ -343,7 +361,7 @@ export function startFriendUi(opts) {
|
|
|
343
361
|
return;
|
|
344
362
|
}
|
|
345
363
|
res.writeHead(206, {
|
|
346
|
-
"content-type":
|
|
364
|
+
"content-type": ctypeHeader,
|
|
347
365
|
"content-disposition": disposition,
|
|
348
366
|
"accept-ranges": "bytes",
|
|
349
367
|
"content-range": `bytes ${start}-${end}/${size}`,
|
|
@@ -353,7 +371,7 @@ export function startFriendUi(opts) {
|
|
|
353
371
|
return;
|
|
354
372
|
}
|
|
355
373
|
res.writeHead(200, {
|
|
356
|
-
"content-type":
|
|
374
|
+
"content-type": ctypeHeader,
|
|
357
375
|
"content-disposition": disposition,
|
|
358
376
|
"accept-ranges": "bytes",
|
|
359
377
|
"content-length": String(size),
|
package/docs/INSTALL.md
CHANGED
|
@@ -13,13 +13,27 @@ transparently between any two daemons that are Carrier friends.
|
|
|
13
13
|
| | minimum |
|
|
14
14
|
|---|---|
|
|
15
15
|
| Node.js | **20+** |
|
|
16
|
-
| OS | Linux (`x86_64`, `aarch64`)
|
|
16
|
+
| OS | Linux (`x86_64`, `aarch64`), macOS (`aarch64`, `x86_64`), or **Windows 10 / 11 via WSL2** ([Windows guide](INSTALL-WINDOWS.md)) |
|
|
17
17
|
| Root / sudo | Yes — needed to create the TUN device. The daemon drops back to user-mode for everything else. |
|
|
18
18
|
| Outbound network | TCP and UDP to public Carrier bootstrap nodes (ports 33445 + 443). No inbound port needed; no public IP needed. |
|
|
19
19
|
|
|
20
|
-
Windows
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
### Windows
|
|
21
|
+
|
|
22
|
+
Windows **is** supported — but the daemon runs inside **WSL2** (Windows
|
|
23
|
+
Subsystem for Linux), a real Linux environment Windows installs for you, not
|
|
24
|
+
on Windows directly. Requirements:
|
|
25
|
+
|
|
26
|
+
- **Windows 11**, or **Windows 10 version 2004 or later** (build 19041+) —
|
|
27
|
+
this is what the one-command `wsl --install` needs.
|
|
28
|
+
- **Fast (direct UDP) path needs Windows 11, version 22H2 or later.** There you
|
|
29
|
+
turn on WSL **mirrored networking mode**, which lets the direct UDP
|
|
30
|
+
hole-punch succeed (~200 ms — smooth for live video). On **Windows 10**
|
|
31
|
+
mirrored mode doesn't exist, so WSL's NAT blocks inbound UDP and the SDK
|
|
32
|
+
falls back to the TCP relay: it still works, just slower (fine for SSH, chat,
|
|
33
|
+
and most browsing; live sports may stutter).
|
|
34
|
+
|
|
35
|
+
👉 **Non-technical step-by-step for Windows users: [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md)**
|
|
36
|
+
(install WSL → install Node.js → install decentlan, three steps).
|
|
23
37
|
|
|
24
38
|
## Install
|
|
25
39
|
|
|
@@ -52,7 +66,7 @@ Wait ~60–90s, then verify:
|
|
|
52
66
|
|
|
53
67
|
```bash
|
|
54
68
|
agentnet ipam list # should show 6+ peers
|
|
55
|
-
agentnet diag | grep tun # your allocated 10.86.
|
|
69
|
+
agentnet diag | grep tun # your allocated 10.86.x.x
|
|
56
70
|
```
|
|
57
71
|
|
|
58
72
|
> ### If `ipam list` shows only yourself after 2 minutes
|
|
@@ -63,7 +77,7 @@ agentnet diag | grep tun # your allocated 10.86.1.x
|
|
|
63
77
|
>
|
|
64
78
|
> 1. Re-send the friend-request directly through your live daemon:
|
|
65
79
|
> ```bash
|
|
66
|
-
> agentnet friend-request
|
|
80
|
+
> agentnet friend-request NsuN81dZdEoyvwEFgWaHkT8SPJB6UWeRmdYcCGFV5CdbbPXoK2RM
|
|
67
81
|
> ```
|
|
68
82
|
> Then wait 60s and re-check `agentnet ipam list`.
|
|
69
83
|
>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.137",
|
|
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",
|