@decentnetwork/beagle 0.1.123 → 0.1.125
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/dist/desktop/app.js +13 -2
- package/dist/server.js +95 -38
- package/dist/upload-settle.d.ts +13 -0
- package/dist/upload-settle.js +42 -0
- package/package.json +2 -2
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.125";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -1697,12 +1697,23 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1697
1697
|
}, [append, lang, onReloadFriends]);
|
|
1698
1698
|
return { thread, handle, removeFriends, adoptServicePeers };
|
|
1699
1699
|
}
|
|
1700
|
+
var REQUEST_AVATAR_HOST = "ens-gateway.beaglechat.workers.dev";
|
|
1700
1701
|
var MAX_URL = 2048;
|
|
1701
1702
|
function requestAvatarUrl(r) {
|
|
1702
1703
|
const u = r && r.avatarUrl;
|
|
1703
1704
|
if (typeof u !== "string" || !u || u.length > MAX_URL)
|
|
1704
1705
|
return null;
|
|
1705
|
-
|
|
1706
|
+
let parsed;
|
|
1707
|
+
try {
|
|
1708
|
+
parsed = new URL(u);
|
|
1709
|
+
} catch {
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
if (parsed.protocol !== "https:" || parsed.host !== REQUEST_AVATAR_HOST)
|
|
1713
|
+
return null;
|
|
1714
|
+
if (parsed.username || parsed.password)
|
|
1715
|
+
return null;
|
|
1716
|
+
return parsed.href;
|
|
1706
1717
|
}
|
|
1707
1718
|
function requestPunkId(r) {
|
|
1708
1719
|
const p = r && r.punkId;
|
package/dist/server.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import net from "node:net";
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream, realpathSync } from "node:fs";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
|
|
@@ -29,6 +29,7 @@ import { readFriendStore, pickPunk } from "./friend-store.js";
|
|
|
29
29
|
import { desktopRequestRow } from "./pending-requests.js";
|
|
30
30
|
import { createDiscoverFetch } from "./discover-cache.js";
|
|
31
31
|
import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
|
|
32
|
+
import { settleUpload } from "./upload-settle.js";
|
|
32
33
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
33
34
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
34
35
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
@@ -356,21 +357,45 @@ async function npmGlobalInstall(pkg, nodePath) {
|
|
|
356
357
|
{ cmd: "npm", args },
|
|
357
358
|
...(existsSync(npmCli) ? [{ cmd: process.execPath, args: [npmCli, ...args] }] : []),
|
|
358
359
|
];
|
|
360
|
+
const runOnce = (cmd, cmdArgs, timeoutMs = 5 * 60_000) => new Promise((resolveRun) => {
|
|
361
|
+
const ch = spawn(cmd, cmdArgs, { shell: process.platform === "win32" });
|
|
362
|
+
let out = "";
|
|
363
|
+
const timer = setTimeout(() => ch.kill("SIGKILL"), timeoutMs);
|
|
364
|
+
ch.stdout?.on("data", (c) => (out += c));
|
|
365
|
+
ch.stderr?.on("data", (c) => (out += c));
|
|
366
|
+
ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
|
|
367
|
+
ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
|
|
368
|
+
});
|
|
359
369
|
for (const a of attempts) {
|
|
360
|
-
const r = await
|
|
361
|
-
const ch = spawn(a.cmd, a.args, { shell: process.platform === "win32" });
|
|
362
|
-
let out = "";
|
|
363
|
-
const timer = setTimeout(() => ch.kill("SIGKILL"), 5 * 60_000);
|
|
364
|
-
ch.stdout?.on("data", (c) => (out += c));
|
|
365
|
-
ch.stderr?.on("data", (c) => (out += c));
|
|
366
|
-
ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
|
|
367
|
-
ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
|
|
368
|
-
});
|
|
370
|
+
const r = await runOnce(a.cmd, a.args);
|
|
369
371
|
if (r.spawnErr)
|
|
370
372
|
continue; // npm not found this way — try the next resolver
|
|
371
373
|
const tail = r.out.slice(-2000);
|
|
372
374
|
if (r.code === 0)
|
|
373
375
|
return { ok: true, log: tail };
|
|
376
|
+
// Root-owned prefix, but this account may run root commands without a
|
|
377
|
+
// password (WSL's default user, cloud images). A privileged install is then
|
|
378
|
+
// something the person can already do with no prompt, and going through
|
|
379
|
+
// `sudo -n` EVERY time keeps the layout consistent: the dir stays
|
|
380
|
+
// root-owned and the next click takes the same path, so no loop. Never
|
|
381
|
+
// prompts (-n), never changes permissions. -H gives npm root's own cache,
|
|
382
|
+
// so no root-owned files land in ~/.npm. Absolute node + npm-cli and an
|
|
383
|
+
// explicit --prefix, because sudo resets PATH and HOME and root's npm
|
|
384
|
+
// could otherwise resolve a different global prefix than this user's.
|
|
385
|
+
if (/EACCES|permission denied/i.test(r.out) && (await canSudoNonInteractive())) {
|
|
386
|
+
const npmCmd = a.cmd === "npm" ? resolveNpmOnPath(npmCli) : [a.cmd, a.args[0]];
|
|
387
|
+
if (npmCmd.length) {
|
|
388
|
+
const [exe, ...pre0] = npmCmd;
|
|
389
|
+
const pre = await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
|
|
390
|
+
const prefix = pre.code === 0 ? pre.out.trim().split("\n").pop()?.trim() : "";
|
|
391
|
+
if (prefix) {
|
|
392
|
+
const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, "--prefix", prefix]);
|
|
393
|
+
if (s.code === 0)
|
|
394
|
+
return { ok: true, log: s.out.slice(-2000) };
|
|
395
|
+
return { ok: false, error: `sudo -n npm exited ${s.code}`, log: s.out.slice(-2000) };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
374
399
|
// Do NOT tell people to re-run it with sudo. `sudo npm i -g` succeeds and
|
|
375
400
|
// leaves the package directory owned by root, so the NEXT unprivileged
|
|
376
401
|
// update fails exactly the same way — the advice was the thing keeping
|
|
@@ -412,9 +437,48 @@ async function globalNpmWritable() {
|
|
|
412
437
|
return { ok: true };
|
|
413
438
|
}
|
|
414
439
|
catch {
|
|
440
|
+
// Not writable by us — but npmGlobalInstall falls back to `sudo -n` when
|
|
441
|
+
// that needs no password, so the button does work there.
|
|
442
|
+
if (await canSudoNonInteractive())
|
|
443
|
+
return { ok: true };
|
|
415
444
|
return { ok: false, reason: await globalNpmOwnershipHint(BEAGLE_PKG) };
|
|
416
445
|
}
|
|
417
446
|
}
|
|
447
|
+
/** The `npm` this process would run from PATH, as an absolute command sudo can
|
|
448
|
+
* run after it resets PATH: `[node, npm-cli.js]` when npm is a symlink to the
|
|
449
|
+
* JS entry (nvm, nodejs.org, Debian's /usr/bin/npm → /usr/share/nodejs/npm),
|
|
450
|
+
* so the same node runs it; else the resolved file itself. Falls back to the
|
|
451
|
+
* npm-cli.js beside the running node. Empty when none is found. */
|
|
452
|
+
function resolveNpmOnPath(besideNode) {
|
|
453
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
454
|
+
if (!dir)
|
|
455
|
+
continue;
|
|
456
|
+
const p = join(dir, "npm");
|
|
457
|
+
if (!existsSync(p))
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
const real = realpathSync(p);
|
|
461
|
+
return real.endsWith(".js") ? [process.execPath, real] : [real];
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return existsSync(besideNode) ? [process.execPath, besideNode] : [];
|
|
468
|
+
}
|
|
469
|
+
/** True when `sudo -n true` succeeds: this account runs root commands without
|
|
470
|
+
* a password prompt. Never prompts. Cached for a minute. */
|
|
471
|
+
let sudoNoPassCache = null;
|
|
472
|
+
async function canSudoNonInteractive() {
|
|
473
|
+
if (process.platform === "win32" || process.getuid?.() === 0)
|
|
474
|
+
return false;
|
|
475
|
+
if (sudoNoPassCache && Date.now() - sudoNoPassCache.at < 60_000)
|
|
476
|
+
return sudoNoPassCache.ok;
|
|
477
|
+
const { execFile } = await import("node:child_process");
|
|
478
|
+
const ok = await new Promise((res) => execFile("sudo", ["-n", "true"], { timeout: 5000 }, (err) => res(!err)));
|
|
479
|
+
sudoNoPassCache = { at: Date.now(), ok };
|
|
480
|
+
return ok;
|
|
481
|
+
}
|
|
418
482
|
/**
|
|
419
483
|
* What to tell someone whose global npm directory this process cannot write.
|
|
420
484
|
*
|
|
@@ -1425,42 +1489,33 @@ export function startBeagleServer(opts) {
|
|
|
1425
1489
|
else {
|
|
1426
1490
|
destDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
|
|
1427
1491
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
// Reuse the existing path when the size already matches, so the
|
|
1436
|
-
// upload overwrites that copy instead of growing a new one. Tradeoff:
|
|
1437
|
-
// a different file with the same name AND the same byte count
|
|
1438
|
-
// replaces the older copy rather than sitting beside it — the same
|
|
1439
|
-
// thing any "save to downloads" does, and far better than unbounded
|
|
1440
|
-
// duplication. Hashing instead would mean buffering the whole upload.
|
|
1492
|
+
// The upload ALWAYS lands in a fresh temp file first, never on an
|
|
1493
|
+
// existing path. It used to open the same-name, same-size file in
|
|
1494
|
+
// downloads/ for writing ("re-send dedupe") — and the most natural
|
|
1495
|
+
// way to forward a received file is to pick exactly that file. The
|
|
1496
|
+
// write truncated the very file the browser was still streaming the
|
|
1497
|
+
// upload from: a 29.7 MB recording was cut to 832 KB on disk and the
|
|
1498
|
+
// 832 KB was sent as "delivered" (mac-dev → nv4090-wsl, 2026-11-02).
|
|
1441
1499
|
const uploadSize = Number(req.headers["content-length"] ?? NaN);
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
const base = dot > 0 ? safe.slice(0, dot) : safe;
|
|
1445
|
-
const ext = dot > 0 ? safe.slice(dot) : "";
|
|
1446
|
-
for (let i = 1; existsSync(destPath); i++) {
|
|
1447
|
-
// Same size is a cheap, reliable proxy here: these are the
|
|
1448
|
-
// sender's own files, not adversarial input, and the full
|
|
1449
|
-
// compare would mean buffering the upload to hash it.
|
|
1450
|
-
if (Number.isFinite(uploadSize) && statSync(destPath).size === uploadSize)
|
|
1451
|
-
break;
|
|
1452
|
-
destName = `${base}-${i}${ext}`;
|
|
1453
|
-
destPath = join(destDir, destName);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1500
|
+
const tmpPath = join(destDir, `.${safe}.${process.pid}-${Date.now()}.part`);
|
|
1501
|
+
let destPath = tmpPath;
|
|
1456
1502
|
try {
|
|
1457
1503
|
await new Promise((resolve2, reject) => {
|
|
1458
|
-
const ws = fs.createWriteStream(
|
|
1504
|
+
const ws = fs.createWriteStream(tmpPath);
|
|
1459
1505
|
req.pipe(ws);
|
|
1460
1506
|
req.on("error", reject);
|
|
1507
|
+
// A browser that gives up mid-upload closes the socket; without
|
|
1508
|
+
// this the partial file could still be handed to the daemon.
|
|
1509
|
+
req.on("close", () => { if (!req.complete)
|
|
1510
|
+
reject(new Error("upload aborted by the browser")); });
|
|
1461
1511
|
ws.on("error", reject);
|
|
1462
1512
|
ws.on("finish", () => resolve2());
|
|
1463
1513
|
});
|
|
1514
|
+
const got = statSync(tmpPath).size;
|
|
1515
|
+
if (Number.isFinite(uploadSize) && got !== uploadSize) {
|
|
1516
|
+
throw new Error(`upload incomplete (${got} of ${uploadSize} bytes) — nothing was sent`);
|
|
1517
|
+
}
|
|
1518
|
+
destPath = keep ? await settleUpload(tmpPath, destDir, safe) : tmpPath;
|
|
1464
1519
|
const r = await opts.call({ op: "file-send", userid, path: destPath });
|
|
1465
1520
|
if (!r.ok) {
|
|
1466
1521
|
sendJson(res, 502, r);
|
|
@@ -1474,6 +1529,8 @@ export function startBeagleServer(opts) {
|
|
|
1474
1529
|
finally {
|
|
1475
1530
|
if (!keep)
|
|
1476
1531
|
await fsp.rm(destDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1532
|
+
else
|
|
1533
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => undefined); // no-op once settled
|
|
1477
1534
|
}
|
|
1478
1535
|
return;
|
|
1479
1536
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Give a finished upload (already complete in `tmpPath`, inside `dir`) its
|
|
3
|
+
* final name, without ever writing over an existing file.
|
|
4
|
+
*
|
|
5
|
+
* Re-sending a file that is already in downloads/ must not grow another copy
|
|
6
|
+
* (observed: four byte-identical copies of one 52 MB video). The old dedupe
|
|
7
|
+
* did that by opening the same-name, same-size file for WRITING before the
|
|
8
|
+
* upload arrived — which truncated the source when that file was the one
|
|
9
|
+
* being uploaded (forwarding a received file). Now the comparison happens
|
|
10
|
+
* after the upload is safely on disk: an existing copy is reused only when
|
|
11
|
+
* its bytes are identical; a different file with the same name gets `-N`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function settleUpload(tmpPath: string, dir: string, name: string): Promise<string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { rename, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Give a finished upload (already complete in `tmpPath`, inside `dir`) its
|
|
7
|
+
* final name, without ever writing over an existing file.
|
|
8
|
+
*
|
|
9
|
+
* Re-sending a file that is already in downloads/ must not grow another copy
|
|
10
|
+
* (observed: four byte-identical copies of one 52 MB video). The old dedupe
|
|
11
|
+
* did that by opening the same-name, same-size file for WRITING before the
|
|
12
|
+
* upload arrived — which truncated the source when that file was the one
|
|
13
|
+
* being uploaded (forwarding a received file). Now the comparison happens
|
|
14
|
+
* after the upload is safely on disk: an existing copy is reused only when
|
|
15
|
+
* its bytes are identical; a different file with the same name gets `-N`.
|
|
16
|
+
*/
|
|
17
|
+
export async function settleUpload(tmpPath, dir, name) {
|
|
18
|
+
const dot = name.lastIndexOf(".");
|
|
19
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
20
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
21
|
+
const size = statSync(tmpPath).size;
|
|
22
|
+
for (let i = 0;; i++) {
|
|
23
|
+
const candidate = join(dir, i === 0 ? name : `${base}-${i}${ext}`);
|
|
24
|
+
if (!existsSync(candidate)) {
|
|
25
|
+
await rename(tmpPath, candidate);
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
if (statSync(candidate).size === size && (await sha256File(candidate)) === (await sha256File(tmpPath))) {
|
|
29
|
+
await rm(tmpPath, { force: true });
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function sha256File(path) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const h = createHash("sha256");
|
|
37
|
+
createReadStream(path)
|
|
38
|
+
.on("data", (c) => h.update(c))
|
|
39
|
+
.on("error", reject)
|
|
40
|
+
.on("end", () => resolve(h.digest("hex")));
|
|
41
|
+
});
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.125",
|
|
4
4
|
"description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"start": "node dist/cli.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@decentnetwork/beagle-ui": "^0.2.
|
|
29
|
+
"@decentnetwork/beagle-ui": "^0.2.35",
|
|
30
30
|
"@decentnetwork/chat-components": "^0.1.3",
|
|
31
31
|
"@decentnetwork/lan": "^0.1.313",
|
|
32
32
|
"@decentnetwork/peer": "^0.1.170",
|