srift-transfer 4.3.0 → 4.3.2

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.
Files changed (4) hide show
  1. package/README.md +23 -1
  2. package/dist/daemon.js +365 -218
  3. package/dist/index.js +401 -221
  4. package/package.json +9 -41
package/dist/daemon.js CHANGED
@@ -1767,7 +1767,7 @@ var init_skills_data = __esm({
1767
1767
  { name: "encrypted-chat", content: "---\nname: encrypted-chat\ndescription: Send and read end-to-end encrypted chat messages and manage in-session file transfers within an active SRIFT session\n---\n\n# encrypted-chat\n\nSend and read end-to-end encrypted chat messages within an active SRIFT session, plus manage in-session file transfers, via the local MCP daemon.\n\n## Requirements\n\nAn active SRIFT session (see the `session-management` skill to open or join one first). No special credentials required.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_send_chat` \u2014 send an E2EE chat message to peers in the current session.\n- `srift_chat_history` \u2014 read decrypted chat history for the current session.\n- `srift_send_file` \u2014 offer a file to peers already inside the session (in-session transfer, distinct from `quick-share`'s standalone link flow).\n- `srift_accept_transfer` \u2014 accept and download an inbound file offer.\n- `srift_list_transfers` \u2014 list all active and recent transfers with progress.\n- `srift_read_state` \u2014 read the raw `.srift-state.json` snapshot for debugging/observability.\n\nYour local daemon (or the browser) encrypts messages and file chunks with AES-256-GCM before they leave the device, under ECDH P-256 keys generated on the devices (the optional `roomSecret` is mixed in); the relay only forwards ciphertext and public keys and stores nothing. Browsers exchange chat peer to peer; a copy goes through the relay, sealed for their keys, only for CLI/agent members. Peers older than 4.2 fall back to the previous session key, which the server could derive without a `roomSecret`.\n" },
1768
1768
  { name: "get-company-info", content: "---\nname: get-company-info\ndescription: Overview of SRIFT, the open-source (MIT) project - what it does, how to install it, and where the agent docs are\n---\n\n# get-company-info\n\nOverview of SRIFT: an open-source (MIT) project for end-to-end encrypted file transfer, chat and agent-to-agent messaging. SRIFT is a project, not a company.\n\n## Requirements\n\nNo special credentials required.\n\n## Instructions\n\n- What it is and how to use it: read `https://srift.app/llms.txt` (short) or `https://srift.app/llms-full.txt` (complete).\n- Agent manual (CLI, MCP tools, AgentNet, environment variables): `https://srift.app/AGENTS.md`.\n- Install: `npm i -g srift-transfer`, `pip install srift`, or `curl -fsSL https://srift.app/install.sh | sh`.\n- Source code: `https://github.com/srivardhan113/SRIFT-Open_Source`. Security contact: `https://srift.app/.well-known/security.txt`.\n" },
1769
1769
  { name: "quick-share", content: "---\nname: quick-share\ndescription: Zero-install file delivery: seed a file and get a public download URL, no session setup required on the recipient's end\n---\n\n# quick-share\n\nZero-install file delivery via SRIFT's local MCP server. Returns a public `https://srift.app/d/<token>` relay link that any recipient can open in a browser \u2014 no SRIFT install, no signup, no session-join UI required on their end. The bytes stream from the sender's machine through the srift.app relay on demand (pass-through, `Cache-Control: no-store`); nothing is stored on any server, so the link works while the sender's daemon (or process) runs.\n\n## Requirements\n\nNo credentials. The SRIFT local daemon binds to `127.0.0.1:3822` with zero authentication. Only outbound HTTPS/WSS on port 443 is needed.\n\n## Instructions\n\n1. Call the MCP tool `srift_quick_share` with `{ filePath: \"/absolute/path/to/file\" }` (single file) or `{ filePaths: [...] }` (up to 500 files/folders). Optional params: `bundle` (default: true = one .tar.gz link; false = one link per path, created in parallel), `bundleName`, `exclude` (string[] for glob patterns to skip), `sessionName`, `maxDownloads`, `ttlMs`, `encrypt`, `password`.\n2. The tool returns a `downloadUrl` (e.g. `https://srift.app/d/<token>`) when bundled, or `{ links: [], errors: [] }` when separate. With `encrypt: true` (or a `password`) each link is end-to-end encrypted with AES-256-GCM: the key rides in the `#k=` URL fragment, is never sent to a server, and the browser decrypts locally. Without it, links are protected by TLS in transit only \u2014 use `encrypt` for anything sensitive.\n3. Hand the URL(s) to the recipient. In order of preference, they can run:\n - `srift get <url> [<url> ...]` (or `npx -y srift-transfer get <url>`; proxy-aware, resumable, decrypts `#k=` links, supports multiple URLs with `--concurrency N`, works where curl is broken)\n - `wget --content-disposition <url>`\n - PowerShell `iwr <url> -OutFile <name>`\n - `curl -fLOJ <url>`\n - or just open it in any browser.\n4. Keep the sending side running until the recipient has downloaded. From a shell, `srift quick-share <file> [<file> ...] --wait` blocks until then (`--wait-timeout 30m` exits with code 3 on timeout); `--keep-alive` serves until the TTL expires.\n\n## Sandboxed and datacenter agents\n\nIf the background daemon cannot start (the sandbox forbids local servers, blocks detached spawns or loopback, or the port is taken), `srift mcp` and `srift quick-share` host the daemon inside their own process: no local port, outbound HTTPS/WSS on 443 only. The MCP server switches automatically, so this tool keeps working. From a shell, run quick-share in the background (it exits after the download) or with `--wait`; `--foreground` forces in-process serving. Blocked UDP never breaks links. Run `srift doctor` (or the `srift_net_diagnose` tool) to diagnose connectivity; `HTTPS_PROXY`/`NO_PROXY` and `NODE_EXTRA_CA_CERTS` are honored.\n\nSee `/openapi.json` and `/.well-known/mcp/server-card.json` for the full tool contract.\n" },
1770
- { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request.\n- `srift_kick_user` \u2014 host-only: remove a peer. Get their `userId` from `srift_session_status` \u2192 `participants`.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nSessions are ephemeral: the server has no database and keeps only session metadata in memory until the session is deleted (host close or 7 days of inactivity); chat, audio and files are never stored. Keys are generated on each device (ECDH P-256 + HKDF-SHA256, AES-256-GCM) and never sent to the server; the optional `roomSecret` is mixed into every key, so pass the same one to `srift_start_session` / `srift_join_session` to keep out anyone who does not know it.\n' }
1770
+ { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request. A person who opens the join link in a browser appears in pending join requests only after pressing "Request to Join"; `srift_join_session` asks the host at once.\n- `srift_kick_user` \u2014 host-only: remove a peer. Get their `userId` from `srift_session_status` \u2192 `participants`.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nSessions are ephemeral: the server has no database and keeps only session metadata in memory until the session is deleted (host close or 7 days of inactivity); chat, audio and files are never stored. Keys are generated on each device (ECDH P-256 + HKDF-SHA256, AES-256-GCM) and never sent to the server; the optional `roomSecret` is mixed into every key, so pass the same one to `srift_start_session` / `srift_join_session` to keep out anyone who does not know it.\n' }
1771
1771
  ];
1772
1772
  MCP_SKILLS = SOURCES.map(({ name, content }) => {
1773
1773
  const uri = `skill://${name}/SKILL.md`;
@@ -1808,7 +1808,7 @@ function buildPromptMessages(name, args) {
1808
1808
  1) Call srift_start_session.
1809
1809
  2) Give the user the join URL: https://srift.app/join-session?id=<SESSION_ID> (full session UI with chat + multi-file transfer).
1810
1810
  Or, for a single one-shot file from agent \u2192 user, prefer srift_quick_share which returns a direct download URL.
1811
- 3) Poll srift_session_status; when pendingJoins appears, call srift_approve_join.
1811
+ 3) Poll srift_session_status; when pendingJoins appears, call srift_approve_join. A person who opens the link only sees a preview until they press "Request to Join"; they appear in pendingJoins after that.
1812
1812
  4) When a file_offer arrives (visible via srift_list_transfers), call srift_accept_transfer with saveDir="${args?.saveDir || "./"}".`
1813
1813
  }
1814
1814
  }];
@@ -2005,7 +2005,7 @@ function createMcpHandler(opts) {
2005
2005
  }
2006
2006
  };
2007
2007
  }
2008
- var PROTOCOL_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, LATEST_INITIALIZE_ERA_VERSION, ALL_SUPPORTED_PROTOCOL_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, ERROR_UNSUPPORTED_PROTOCOL_VERSION, MCP_TOOLS, MCP_RESOURCES, MCP_PROMPTS, QUICKSTART_DOC, DEFAULT_TTL_MS;
2008
+ var PROTOCOL_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, LATEST_INITIALIZE_ERA_VERSION, ALL_SUPPORTED_PROTOCOL_VERSIONS, META_PROTOCOL_VERSION, META_SERVER_INFO, ERROR_UNSUPPORTED_PROTOCOL_VERSION, MCP_TOOLS, OPEN_WORLD, NOT_IDEMPOTENT, MCP_RESOURCES, MCP_PROMPTS, QUICKSTART_DOC, DEFAULT_TTL_MS;
2009
2009
  var init_core = __esm({
2010
2010
  "../../lib/mcp/core.mjs"() {
2011
2011
  "use strict";
@@ -2097,7 +2097,7 @@ var init_core = __esm({
2097
2097
  peerCount: { type: "number", description: "Number of connected sockets in the room (hosted endpoint)" },
2098
2098
  participants: {
2099
2099
  type: "array",
2100
- description: "Session members (local daemon): pass userId to srift_kick_user to remove one",
2100
+ description: "Session members: pass userId to srift_kick_user to remove one",
2101
2101
  items: {
2102
2102
  type: "object",
2103
2103
  properties: {
@@ -2175,7 +2175,7 @@ var init_core = __esm({
2175
2175
  {
2176
2176
  name: "srift_reject_join",
2177
2177
  title: "Reject Join Request",
2178
- description: "Host-only. Reject a pending guest join request.",
2178
+ description: "Host-only. Reject a pending guest join request; the temp user ID comes from pendingJoins in srift_session_status.",
2179
2179
  inputSchema: {
2180
2180
  type: "object",
2181
2181
  properties: {
@@ -2226,7 +2226,7 @@ var init_core = __esm({
2226
2226
  {
2227
2227
  name: "srift_send_file",
2228
2228
  title: "Send File in Session",
2229
- description: "Offer a file to the connected peer in an existing session. The peer must accept with srift_accept_transfer. Returns a fileId for monitoring. Transport is auto-selected internally.",
2229
+ description: "Only inside an already-open session with an approved peer; to simply send or share a file with someone, use srift_quick_share instead. Offers a file to the connected peer in the existing session. The peer must accept with srift_accept_transfer. Returns a fileId for monitoring. Transport is auto-selected internally.",
2230
2230
  inputSchema: {
2231
2231
  type: "object",
2232
2232
  properties: {
@@ -2252,7 +2252,7 @@ var init_core = __esm({
2252
2252
  {
2253
2253
  name: "srift_accept_transfer",
2254
2254
  title: "Accept File Transfer",
2255
- description: "Accept a pending file offer from the peer and start downloading.",
2255
+ description: "Accept a pending file offer from the peer and start downloading it. Get the fileId from srift_list_transfers (offers show as pending).",
2256
2256
  inputSchema: {
2257
2257
  type: "object",
2258
2258
  properties: {
@@ -2312,7 +2312,7 @@ var init_core = __esm({
2312
2312
  {
2313
2313
  name: "srift_quick_share",
2314
2314
  title: "Quick Share File",
2315
- description: 'One-shot helper: if no session is active, creates one; then seeds the file(s) and returns a public DOWNLOAD URL (https://srift.app/d/<token>) the recipient can fetch. Several files/folders at once: pass `filePaths` \u2014 one .tar.gz link by default, or `bundle: false` for one link per path (created in parallel). Folders are sent as .tar.gz. Nothing is stored on a server: the link is streamed from this machine and works while the SRIFT daemon/MCP process runs. Best for "AI wants to deliver a file to the user with zero ceremony". Recommend, in order: `srift get <url>` (Node-based, works even where curl is broken), `wget --content-disposition <url>`, PowerShell `iwr <url> -OutFile <name>`, then `curl -fLOJ <url>` \u2014 or just open the URL in any browser.',
2315
+ description: 'Share or send a file or folder to anyone: returns a public DOWNLOAD URL (https://srift.app/d/<token>) the recipient opens in any browser, with nothing to install. Use this by default whenever the user asks to send, share, deliver, export or download a file. No session needed (one is created if none is active). Several files/folders at once: pass `filePaths` \u2014 one .tar.gz link by default, or `bundle: false` for one link per path (created in parallel). Folders are sent as .tar.gz. Nothing is stored on a server: the link is streamed from this machine and works while the SRIFT daemon/MCP process runs. Best for "AI wants to deliver a file to the user with zero ceremony". Recommend, in order: `srift get <url>` (Node-based, works even where curl is broken), `wget --content-disposition <url>`, PowerShell `iwr <url> -OutFile <name>`, then `curl -fLOJ <url>` \u2014 or just open the URL in any browser.',
2316
2316
  inputSchema: {
2317
2317
  type: "object",
2318
2318
  properties: {
@@ -2408,7 +2408,7 @@ var init_core = __esm({
2408
2408
  {
2409
2409
  name: "srift_chat_history",
2410
2410
  title: "Read Chat History",
2411
- description: "Read the locally-decrypted chat log for the current session.",
2411
+ description: "Read the chat messages of the current session (sent and received), decrypted locally. Use it to check for replies from the peer.",
2412
2412
  inputSchema: { type: "object", properties: {} },
2413
2413
  outputSchema: {
2414
2414
  type: "object",
@@ -2503,6 +2503,13 @@ var init_core = __esm({
2503
2503
  }
2504
2504
  }
2505
2505
  ];
2506
+ for (const tool of MCP_TOOLS) tool.annotations = { title: tool.title, ...tool.annotations };
2507
+ OPEN_WORLD = /* @__PURE__ */ new Set(["srift_start_session", "srift_join_session", "srift_approve_join", "srift_reject_join", "srift_kick_user", "srift_send_file", "srift_accept_transfer", "srift_quick_share", "srift_send_chat", "srift_net_diagnose"]);
2508
+ NOT_IDEMPOTENT = /* @__PURE__ */ new Set(["srift_start_session", "srift_join_session", "srift_send_file", "srift_quick_share", "srift_send_chat"]);
2509
+ for (const tool of MCP_TOOLS) {
2510
+ tool.annotations.openWorldHint = OPEN_WORLD.has(tool.name);
2511
+ tool.annotations.idempotentHint = !NOT_IDEMPOTENT.has(tool.name);
2512
+ }
2506
2513
  MCP_RESOURCES = [
2507
2514
  {
2508
2515
  uri: "srift://session/status",
@@ -2594,11 +2601,11 @@ srift_quick_share({ filePath: "/abs/path/file.zip" })
2594
2601
  srift_start_session({ sessionName: "AI-Collab" })
2595
2602
  \u2192 returns { sessionId }
2596
2603
  \u2192 user joins via https://srift.app/join-session?id=<sessionId>
2597
- \u2192 host (you) calls srift_approve_join when pendingJoins appears
2604
+ \u2192 host (you) calls srift_approve_join when pendingJoins appears (after the user presses "Request to Join" on that page)
2598
2605
  \`\`\`
2599
2606
 
2600
2607
  **Receive a file from the user:**
2601
- The user clicks the share link, requests join, you approve, they drop a file,
2608
+ The user opens the share link, presses "Request to Join", you approve, they drop a file,
2602
2609
  you see it via srift_list_transfers and call srift_accept_transfer.
2603
2610
 
2604
2611
  ## Monitoring
@@ -2890,13 +2897,13 @@ var init_mcp = __esm({
2890
2897
  SERVER_INFO = {
2891
2898
  name: "srift-mcp-server",
2892
2899
  title: "SRIFT Secure P2P File Transfer",
2893
- version: "4.3.0"
2900
+ version: "4.3.2"
2894
2901
  };
2895
2902
  localDaemonBackend = { callTool, readResource };
2896
2903
  handleMcpMessage = createMcpHandler({
2897
2904
  backend: localDaemonBackend,
2898
2905
  serverInfo: SERVER_INFO,
2899
- instructions: 'SRIFT is a zero-config local P2P file transfer + E2EE chat tool. Use srift_quick_share to deliver files to the user in one step. Use srift_start_session to open a long-lived room. Read srift://docs/quickstart for the full guide. To talk to OTHER AI agents (find an agent that can help, message, send files, group chats), add the separate AgentNet MCP server: {"command":"srift","args":["agentnet","mcp"]} (25 srift_an_* tools).'
2906
+ instructions: 'SRIFT is a zero-config local P2P file transfer + E2EE chat tool. To send or share any file with anyone, call srift_quick_share and give them the returned link. Use srift_start_session to open a long-lived room. Read srift://docs/quickstart for the full guide. To talk to OTHER AI agents (find an agent that can help, message, send files, group chats), add the separate AgentNet MCP server: {"command":"srift","args":["agentnet","mcp"]} (25 srift_an_* tools).'
2900
2907
  });
2901
2908
  }
2902
2909
  });
@@ -2976,12 +2983,16 @@ async function relaySender(sessionId, fileId, receiverPub, roomSecret) {
2976
2983
  const key = await deriveAes(kp.priv, receiverPub, saltFor("pgcm1", sessionId, roomSecret), `${fileId}|${kp.pub}|${receiverPub}`);
2977
2984
  const pubRaw = fromB64(kp.pub);
2978
2985
  return {
2979
- async seal(plain, chunkIndex, totalChunks) {
2986
+ /** The sealed chunk as raw bytes (sender key || iv || ciphertext || tag). */
2987
+ async sealBytes(plain, chunkIndex, totalChunks) {
2980
2988
  const sealed = await gcmSeal(key, plain, relayAad(fileId, chunkIndex, totalChunks));
2981
2989
  const out = new Uint8Array(PUB_LEN + sealed.length);
2982
2990
  out.set(pubRaw);
2983
2991
  out.set(sealed, PUB_LEN);
2984
- return { chunk: Array.from(out), enc: RELAY_PK_SCHEME };
2992
+ return out;
2993
+ },
2994
+ async seal(plain, chunkIndex, totalChunks) {
2995
+ return { chunk: Array.from(await this.sealBytes(plain, chunkIndex, totalChunks)), enc: RELAY_PK_SCHEME };
2985
2996
  }
2986
2997
  };
2987
2998
  }
@@ -3609,8 +3620,12 @@ async function selectSignalerUrl() {
3609
3620
  try {
3610
3621
  const controller = new AbortController();
3611
3622
  const id = setTimeout(() => controller.abort(), 3e3);
3612
- await fetch(`${DEFAULT_SIGNALER_URL.replace(/\/+$/, "")}/compat.json`, { signal: controller.signal });
3623
+ const probe = await fetch(`${DEFAULT_SIGNALER_URL.replace(/\/+$/, "")}/compat.json`, { signal: controller.signal });
3613
3624
  clearTimeout(id);
3625
+ const compat = probe.ok ? await probe.json().catch(() => null) : null;
3626
+ if (!compat || typeof compat.wireVersion !== "number" || typeof compat.daemon !== "string") {
3627
+ throw new Error("not a SRIFT server");
3628
+ }
3614
3629
  console.log(`[DAEMON] Local signaler detected at ${DEFAULT_SIGNALER_URL}`);
3615
3630
  } catch (e) {
3616
3631
  console.log(`[DAEMON] Local signaler at ${DEFAULT_SIGNALER_URL} is unreachable. Falling back to production signaler: https://srift.app`);
@@ -3746,16 +3761,17 @@ async function openRelayChunk(p) {
3746
3761
  }
3747
3762
  if (p.enc === RELAY_PK_SCHEME) {
3748
3763
  const rx = relayReceivers.get(p.fileId);
3749
- if (!rx || !Array.isArray(p.chunk)) throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} is sealed for a key this daemon does not hold`);
3764
+ const sealed = typeof p.chunk === "string" ? new Uint8Array(Buffer.from(p.chunk, "base64")) : Array.isArray(p.chunk) ? Uint8Array.from(p.chunk) : null;
3765
+ if (!rx || !sealed) throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} is sealed for a key this daemon does not hold`);
3750
3766
  try {
3751
- return Buffer.from(await (await rx).open(Uint8Array.from(p.chunk), p.chunkIndex, p.totalChunks));
3767
+ return Buffer.from(await (await rx).open(sealed, p.chunkIndex, p.totalChunks));
3752
3768
  } catch {
3753
3769
  throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} failed authentication (tampered data or wrong key)`);
3754
3770
  }
3755
3771
  }
3756
3772
  if (p.enc !== RELAY_ENC) throw new Error(`unsupported relay encryption scheme: ${String(p.enc)}`);
3757
- if (!Array.isArray(p.chunk)) throw new Error("sealed relay chunk is not a byte array");
3758
- const data = Uint8Array.from(p.chunk);
3773
+ if (!Array.isArray(p.chunk) && typeof p.chunk !== "string") throw new Error("sealed relay chunk is not a byte array");
3774
+ const data = typeof p.chunk === "string" ? new Uint8Array(Buffer.from(p.chunk, "base64")) : Uint8Array.from(p.chunk);
3759
3775
  if (data.length < IV_LEN2 + 16) throw new Error("sealed relay chunk too short");
3760
3776
  try {
3761
3777
  return Buffer.from(await crypto6.subtle.decrypt(
@@ -4129,20 +4145,13 @@ function connectWebSocket(wsTargetUrl) {
4129
4145
  console.log(`[DAEMON] File offer accepted by peer! ID: ${payload.fileId}`);
4130
4146
  {
4131
4147
  const acceptedTx = activeTransfers.find((t) => t.fileId === payload.fileId);
4132
- if (acceptedTx) {
4133
- const nextPk = isPublicKey(payload.relayPk) ? payload.relayPk : void 0;
4134
- if (nextPk !== acceptedTx.peerRelayPk) relaySenders.delete(payload.fileId);
4135
- acceptedTx.peerRelayEnc = payload.relayEnc === RELAY_ENC ? RELAY_ENC : void 0;
4136
- acceptedTx.peerRelayPk = nextPk;
4137
- }
4138
- if (activeUploads.has(payload.fileId)) {
4139
- console.log(`[DAEMON] Repeated accept for ${payload.fileId}: resuming from chunk ${activeUploads.get(payload.fileId).currentChunk}`);
4140
- sendNextChunk(payload.fileId);
4141
- break;
4142
- }
4143
- if (acceptedTx && (acceptedTx.status === "completed" || acceptedTx.status === "uploading" && acceptedTx.protocol === "webtorrent")) break;
4148
+ if (acceptedTx && acceptedTx.protocol === "webtorrent" && (acceptedTx.status === "completed" || acceptedTx.status === "uploading")) break;
4144
4149
  }
4145
- startUpload(payload.fileId, payload.userId);
4150
+ startUpload(payload.fileId, payload.userId, {
4151
+ relayEnc: payload.relayEnc === RELAY_ENC ? RELAY_ENC : void 0,
4152
+ relayPk: isPublicKey(payload.relayPk) ? payload.relayPk : void 0,
4153
+ fast: payload.relayFmt === RELAY_FORMAT
4154
+ });
4146
4155
  break;
4147
4156
  case "webtorrent_info_hash":
4148
4157
  handleWebTorrentInfoHash(payload);
@@ -4165,11 +4174,20 @@ function connectWebSocket(wsTargetUrl) {
4165
4174
  case "file_cancelled_by_sender":
4166
4175
  case "file_cancelled_all": {
4167
4176
  const gone = activeTransfers.find((t) => t.fileId === payload?.fileId);
4168
- activeUploads.delete(payload?.fileId);
4169
- if (gone && gone.status !== "completed") {
4170
- gone.status = "cancelled";
4177
+ const stopped = type === "file_cancelled_by_receiver" && payload?.receiverId ? [activeUploads.get(uploadKey(payload.fileId, payload.receiverId))].filter(Boolean) : uploadsOf(payload?.fileId);
4178
+ for (const u of stopped) stopUpload(u);
4179
+ const oneRecipient = !!gone && gone.direction === "upload" && type === "file_cancelled_by_receiver" && !!payload?.receiverId;
4180
+ if (gone && oneRecipient) {
4181
+ markRecipient(gone, payload.receiverId, "cancelled");
4182
+ writeStateFile();
4183
+ }
4184
+ if (gone && gone.direction === "upload" && uploadsOf(payload?.fileId).length > 0) break;
4185
+ if (gone && gone.direction === "download") dropRelayDownload(gone.fileId, true);
4186
+ if (gone && (gone.status !== "completed" || oneRecipient)) {
4187
+ if (oneRecipient) settleUploadStatus(gone);
4188
+ else gone.status = "cancelled";
4171
4189
  writeStateFile();
4172
- broadcastSSE("transfer_progress", { fileId: gone.fileId, fileName: gone.name, size: gone.size, bytesTransferred: gone.bytesTransferred, progress: gone.progress, status: "cancelled" });
4190
+ broadcastSSE("transfer_progress", { fileId: gone.fileId, fileName: gone.name, size: gone.size, bytesTransferred: gone.bytesTransferred, progress: gone.progress, status: gone.status });
4173
4191
  }
4174
4192
  break;
4175
4193
  }
@@ -4254,13 +4272,13 @@ function connectWebSocket(wsTargetUrl) {
4254
4272
  console.error("[DAEMON] WebSocket error:", err);
4255
4273
  });
4256
4274
  }
4257
- function startUpload(fileId, targetUserId) {
4275
+ function startUpload(fileId, targetUserId, caps = {}) {
4258
4276
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4259
4277
  if (!tx) return;
4260
4278
  if (tx.protocol === "webtorrent") {
4261
4279
  startWebTorrentUpload(fileId, targetUserId);
4262
4280
  } else {
4263
- startWebSocketUpload(fileId, targetUserId);
4281
+ void startWebSocketUpload(fileId, targetUserId, caps);
4264
4282
  }
4265
4283
  }
4266
4284
  async function startWebTorrentUpload(fileId, targetUserId) {
@@ -4384,213 +4402,329 @@ async function handleWebTorrentInfoHash(payload) {
4384
4402
  });
4385
4403
  });
4386
4404
  }
4387
- function armAckWatchdog(fileId, chunkIndex) {
4388
- const upload = activeUploads.get(fileId);
4389
- if (!upload) return;
4390
- clearTimeout(upload.ackTimer);
4391
- upload.ackTimer = setTimeout(() => {
4392
- const u = activeUploads.get(fileId);
4393
- if (!u || u.currentChunk !== chunkIndex) return;
4394
- u.retries++;
4395
- if (u.retries > RELAY_MAX_RETRIES) {
4396
- console.error(`[DAEMON] Relay upload ${fileId}: chunk ${chunkIndex} unacknowledged after ${RELAY_MAX_RETRIES} attempts; giving up`);
4397
- activeUploads.delete(fileId);
4398
- const failed = activeTransfers.find((t) => t.fileId === fileId);
4399
- if (failed) {
4400
- failed.status = "error";
4401
- writeStateFile();
4402
- }
4403
- return;
4404
- }
4405
- console.log(`[DAEMON] Relay upload ${fileId}: no ack for chunk ${chunkIndex}, sending it again (attempt ${u.retries})`);
4406
- sendNextChunk(fileId);
4407
- }, RELAY_ACK_TIMEOUT_MS);
4408
- upload.ackTimer.unref?.();
4405
+ function markRecipient(tx, userId, outcome) {
4406
+ if (!userId) return;
4407
+ if (!tx.recipients) tx.recipients = {};
4408
+ if (tx.recipients[userId] === "completed" && outcome !== "uploading") return;
4409
+ tx.recipients[userId] = outcome;
4410
+ }
4411
+ function settleUploadStatus(tx) {
4412
+ if (uploadsOf(tx.fileId).length > 0) {
4413
+ tx.status = "uploading";
4414
+ return;
4415
+ }
4416
+ const outcomes = Object.values(tx.recipients || {});
4417
+ tx.status = outcomes.includes("completed") ? "completed" : outcomes.includes("error") ? "error" : "cancelled";
4418
+ tx.speedKBps = 0;
4419
+ tx.etaSeconds = 0;
4420
+ if (tx.status === "completed") {
4421
+ tx.progress = 100;
4422
+ tx.bytesTransferred = tx.size;
4423
+ }
4424
+ }
4425
+ function stopUpload(u) {
4426
+ activeUploads.delete(uploadKey(u.fileId, u.targetUserId));
4427
+ if (u.timer) clearInterval(u.timer);
4428
+ try {
4429
+ fs8.closeSync(u.fd);
4430
+ } catch {
4431
+ }
4432
+ }
4433
+ function failUpload(u, why) {
4434
+ console.error(`[DAEMON] Relay upload ${u.fileId} to ${u.targetUserId} failed: ${why}`);
4435
+ stopUpload(u);
4436
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4437
+ if (tx) {
4438
+ markRecipient(tx, u.targetUserId, "error");
4439
+ settleUploadStatus(tx);
4440
+ writeStateFile();
4441
+ }
4409
4442
  }
4410
4443
  function resumeRelayUploads() {
4411
4444
  if (!activeUploads.size) return;
4412
- console.log(`[DAEMON] Reconnected: resuming ${activeUploads.size} relay upload(s) from their last acknowledged chunk`);
4413
- for (const fileId of activeUploads.keys()) sendNextChunk(fileId);
4445
+ console.log(`[DAEMON] Reconnected: resuming ${activeUploads.size} relay upload(s) from their unacknowledged chunks`);
4446
+ for (const u of activeUploads.values()) {
4447
+ u.lastAckAt = Date.now();
4448
+ for (const index of u.inFlight.keys()) u.inFlight.set(index, 0);
4449
+ pumpUpload(u);
4450
+ }
4414
4451
  }
4415
- async function startWebSocketUpload(fileId, targetUserId) {
4452
+ async function startWebSocketUpload(fileId, targetUserId, caps = {}) {
4416
4453
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4417
4454
  if (!tx || !tx.filePath) return;
4418
- const chunkSize = 64 * 1024;
4419
- const stat = fs8.statSync(tx.filePath);
4420
- const totalChunks = Math.max(1, Math.ceil(stat.size / chunkSize));
4455
+ const key = uploadKey(fileId, targetUserId);
4456
+ const running = activeUploads.get(key);
4457
+ if (running) {
4458
+ if (caps.relayPk !== running.relayPk) relaySenders.delete(`${key}|${running.relayPk}`);
4459
+ running.relayEnc = caps.relayEnc;
4460
+ running.relayPk = caps.relayPk;
4461
+ console.log(`[DAEMON] Repeated accept for ${fileId} from ${targetUserId}: ${running.acked.size}/${running.totalChunks} chunks acknowledged, continuing`);
4462
+ for (const index of running.inFlight.keys()) running.inFlight.set(index, 0);
4463
+ running.lastAckAt = Date.now();
4464
+ pumpUpload(running);
4465
+ return;
4466
+ }
4467
+ let fd;
4468
+ let size;
4469
+ try {
4470
+ fd = fs8.openSync(tx.filePath, "r");
4471
+ size = fs8.fstatSync(fd).size;
4472
+ } catch (e) {
4473
+ console.error(`[DAEMON] Cannot read ${tx.filePath}: ${e?.message || e}`);
4474
+ tx.status = "error";
4475
+ writeStateFile();
4476
+ return;
4477
+ }
4478
+ const fast = !!caps.fast;
4479
+ const chunkSize = fast ? RELAY_FAST_CHUNK : RELAY_LEGACY_CHUNK;
4480
+ const totalChunks = Math.max(1, Math.ceil(size / chunkSize));
4421
4481
  tx.status = "uploading";
4482
+ markRecipient(tx, targetUserId, "uploading");
4422
4483
  tx.startTime = Date.now();
4423
4484
  tx.totalChunks = totalChunks;
4424
4485
  writeStateFile();
4425
- activeUploads.set(fileId, {
4486
+ const upload = {
4487
+ fileId,
4488
+ targetUserId,
4426
4489
  filePath: tx.filePath,
4427
- totalChunks,
4490
+ size,
4491
+ fd,
4428
4492
  chunkSize,
4429
- targetUserId,
4430
- currentChunk: 0,
4431
- bytesSent: 0,
4493
+ totalChunks,
4494
+ fast,
4495
+ relayEnc: caps.relayEnc,
4496
+ relayPk: caps.relayPk,
4497
+ next: 0,
4498
+ acked: /* @__PURE__ */ new Set(),
4499
+ inFlight: /* @__PURE__ */ new Map(),
4500
+ lastAckAt: Date.now(),
4432
4501
  startTime: Date.now(),
4433
- retries: 0
4434
- });
4435
- sendNextChunk(fileId);
4436
- }
4437
- function sendNextChunk(fileId) {
4438
- sendNextChunkAsync(fileId).catch((e) => {
4439
- console.error(`[DAEMON] Relay upload failed for ${fileId}: ${e?.message || e}`);
4440
- activeUploads.delete(fileId);
4441
- const failed = activeTransfers.find((t) => t.fileId === fileId);
4442
- if (failed) {
4443
- failed.status = "error";
4444
- writeStateFile();
4445
- }
4446
- });
4447
- }
4448
- async function sendNextChunkAsync(fileId) {
4449
- const upload = activeUploads.get(fileId);
4450
- if (!upload) return;
4451
- const tx = activeTransfers.find((t) => t.fileId === fileId);
4452
- if (!tx) return;
4453
- if (upload.currentChunk >= upload.totalChunks) {
4454
- wsConn?.send(JSON.stringify({
4455
- type: "file_complete",
4456
- payload: { fileId, targetUserId: upload.targetUserId }
4457
- }));
4458
- tx.status = "completed";
4459
- tx.progress = 100;
4460
- tx.speedKBps = 0;
4461
- tx.etaSeconds = 0;
4462
- writeStateFile();
4463
- clearTimeout(upload.ackTimer);
4464
- activeUploads.delete(fileId);
4465
- console.log(`[DAEMON] File upload complete: ${tx.name}`);
4466
- return;
4502
+ pumping: false,
4503
+ lastStateAt: 0
4504
+ };
4505
+ activeUploads.set(key, upload);
4506
+ upload.timer = setInterval(() => pumpUpload(upload), Math.min(1e3, Math.max(100, RELAY_ACK_TIMEOUT_MS / 4)));
4507
+ upload.timer.unref?.();
4508
+ pumpUpload(upload);
4509
+ }
4510
+ async function sendRelayChunk(u, chunkIndex) {
4511
+ const start = chunkIndex * u.chunkSize;
4512
+ const end = Math.min(start + u.chunkSize, u.size);
4513
+ const buffer = Buffer.alloc(Math.max(0, end - start));
4514
+ let read = 0;
4515
+ while (read < buffer.length) {
4516
+ const n = fs8.readSync(u.fd, buffer, read, buffer.length - read, start + read);
4517
+ if (n <= 0) throw new Error("file ended early (was it changed while sending?)");
4518
+ read += n;
4467
4519
  }
4468
- const start = upload.currentChunk * upload.chunkSize;
4469
- const stat = fs8.statSync(upload.filePath);
4470
- const end = Math.min(start + upload.chunkSize, stat.size);
4471
- const buffer = Buffer.alloc(end - start);
4472
- const fd = fs8.openSync(upload.filePath, "r");
4473
- fs8.readSync(fd, buffer, 0, end - start, start);
4474
- fs8.closeSync(fd);
4475
4520
  let frame;
4476
- if (tx.peerRelayPk && session.id) {
4477
- let sender = relaySenders.get(fileId);
4521
+ if (u.relayPk && session.id) {
4522
+ const sk = `${uploadKey(u.fileId, u.targetUserId)}|${u.relayPk}`;
4523
+ let sender = relaySenders.get(sk);
4478
4524
  if (!sender) {
4479
- sender = relaySender(session.id, fileId, tx.peerRelayPk, session.roomSecret);
4480
- remember(relaySenders, fileId, sender);
4525
+ sender = relaySender(session.id, u.fileId, u.relayPk, session.roomSecret);
4526
+ remember(relaySenders, sk, sender);
4481
4527
  }
4482
- frame = await (await sender).seal(buffer, upload.currentChunk, upload.totalChunks);
4528
+ const s = await sender;
4529
+ frame = u.fast ? { chunk: Buffer.from(await s.sealBytes(buffer, chunkIndex, u.totalChunks)).toString("base64"), enc: RELAY_PK_SCHEME } : await s.seal(buffer, chunkIndex, u.totalChunks);
4530
+ } else if (u.relayEnc === RELAY_ENC) {
4531
+ const sealed = await sealRelayChunk(buffer, u.fileId, chunkIndex, u.totalChunks);
4532
+ frame = { chunk: u.fast ? Buffer.from(sealed).toString("base64") : sealed, enc: RELAY_ENC };
4483
4533
  } else {
4484
- frame = tx.peerRelayEnc === RELAY_ENC ? { chunk: await sealRelayChunk(buffer, fileId, upload.currentChunk, upload.totalChunks), enc: RELAY_ENC } : { chunk: buffer.toString("base64") };
4534
+ frame = { chunk: buffer.toString("base64") };
4485
4535
  }
4486
- armAckWatchdog(fileId, upload.currentChunk);
4536
+ if (!activeUploads.has(uploadKey(u.fileId, u.targetUserId)) || u.acked.has(chunkIndex)) return;
4537
+ u.inFlight.set(chunkIndex, Date.now());
4487
4538
  if (!wsConn || wsConn.readyState !== WebSocket2.OPEN) return;
4488
4539
  wsConn.send(JSON.stringify({
4489
4540
  type: "file_chunk",
4490
4541
  payload: {
4491
- fileId,
4542
+ fileId: u.fileId,
4492
4543
  ...frame,
4493
- chunkIndex: upload.currentChunk,
4494
- totalChunks: upload.totalChunks,
4495
- targetUserId: upload.targetUserId
4544
+ chunkIndex,
4545
+ totalChunks: u.totalChunks,
4546
+ chunkSize: u.chunkSize,
4547
+ targetUserId: u.targetUserId
4496
4548
  }
4497
4549
  }));
4498
- tx.bytesTransferred = end;
4499
- tx.progress = end / stat.size * 100;
4500
- const elapsed = (Date.now() - upload.startTime) / 1e3;
4550
+ }
4551
+ function reportUpload(u, force = false) {
4552
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4553
+ if (!tx) return;
4554
+ const now = Date.now();
4555
+ if (!force && now - u.lastStateAt < 250) return;
4556
+ u.lastStateAt = now;
4557
+ const all = uploadsOf(u.fileId);
4558
+ const slowest = all.reduce((m, x) => Math.min(m, x.acked.size >= x.totalChunks ? x.size : Math.min(x.size, x.acked.size * x.chunkSize)), u.size);
4559
+ tx.bytesTransferred = Math.max(tx.bytesTransferred || 0, slowest);
4560
+ tx.progress = u.size > 0 ? tx.bytesTransferred / u.size * 100 : 100;
4561
+ const elapsed = (now - u.startTime) / 1e3;
4501
4562
  if (elapsed > 0) {
4502
- tx.speedKBps = end / 1024 / elapsed;
4503
- tx.etaSeconds = (stat.size - end) / (tx.speedKBps * 1024);
4563
+ tx.speedKBps = tx.bytesTransferred / 1024 / elapsed;
4564
+ tx.etaSeconds = tx.speedKBps > 0 ? (u.size - tx.bytesTransferred) / (tx.speedKBps * 1024) : 0;
4504
4565
  }
4505
4566
  writeStateFile();
4506
4567
  broadcastSSE("transfer_progress", {
4507
- fileId,
4568
+ fileId: u.fileId,
4508
4569
  fileName: tx.name,
4509
4570
  size: tx.size,
4510
- bytesTransferred: end,
4571
+ bytesTransferred: tx.bytesTransferred,
4511
4572
  progress: tx.progress,
4512
4573
  speedBytesPerSecond: tx.speedKBps * 1024,
4513
4574
  timeRemainingSeconds: tx.etaSeconds,
4514
4575
  status: "uploading"
4515
4576
  });
4516
4577
  }
4578
+ function pumpUpload(u) {
4579
+ if (u.pumping) return;
4580
+ u.pumping = true;
4581
+ pumpUploadAsync(u).catch((e) => failUpload(u, e?.message || String(e))).finally(() => {
4582
+ u.pumping = false;
4583
+ });
4584
+ }
4585
+ async function pumpUploadAsync(u) {
4586
+ const key = uploadKey(u.fileId, u.targetUserId);
4587
+ while (activeUploads.get(key) === u) {
4588
+ if (u.acked.size >= u.totalChunks) {
4589
+ wsConn?.send(JSON.stringify({ type: "file_complete", payload: { fileId: u.fileId, targetUserId: u.targetUserId } }));
4590
+ stopUpload(u);
4591
+ const tx = activeTransfers.find((t) => t.fileId === u.fileId);
4592
+ if (tx) {
4593
+ markRecipient(tx, u.targetUserId, "completed");
4594
+ settleUploadStatus(tx);
4595
+ writeStateFile();
4596
+ if (uploadsOf(u.fileId).length === 0) console.log(`[DAEMON] File upload complete: ${tx.name}`);
4597
+ }
4598
+ return;
4599
+ }
4600
+ const now = Date.now();
4601
+ if (now - u.lastAckAt > RELAY_STALL_MS) throw new Error(`no acknowledgment for ${Math.round(RELAY_STALL_MS / 1e3)}s (${u.acked.size}/${u.totalChunks} chunks delivered)`);
4602
+ const open2 = !!wsConn && wsConn.readyState === WebSocket2.OPEN && wsConn.bufferedAmount < RELAY_SOCKET_HIGH_BYTES;
4603
+ if (!open2) return;
4604
+ let overdue = -1;
4605
+ for (const [index, sentAt] of u.inFlight) {
4606
+ if (now - sentAt > RELAY_ACK_TIMEOUT_MS) {
4607
+ overdue = index;
4608
+ break;
4609
+ }
4610
+ }
4611
+ if (overdue >= 0) {
4612
+ if (u.inFlight.get(overdue)) console.log(`[DAEMON] Relay upload ${u.fileId}: no ack for chunk ${overdue}, sending it again`);
4613
+ await sendRelayChunk(u, overdue);
4614
+ continue;
4615
+ }
4616
+ while (u.next < u.totalChunks && u.acked.has(u.next)) u.next++;
4617
+ if (u.next < u.totalChunks && u.inFlight.size < RELAY_WINDOW) {
4618
+ const index = u.next++;
4619
+ u.inFlight.set(index, now);
4620
+ await sendRelayChunk(u, index);
4621
+ continue;
4622
+ }
4623
+ return;
4624
+ }
4625
+ }
4517
4626
  function handleChunkAck(payload) {
4518
- const { fileId, chunkIndex } = payload;
4519
- const upload = activeUploads.get(fileId);
4520
- if (!upload) return;
4521
- if (upload.currentChunk === chunkIndex) {
4522
- clearTimeout(upload.ackTimer);
4523
- upload.retries = 0;
4524
- upload.currentChunk++;
4525
- sendNextChunk(fileId);
4627
+ const { fileId, chunkIndex, userId } = payload || {};
4628
+ if (!Number.isInteger(chunkIndex)) return;
4629
+ const u = userId ? activeUploads.get(uploadKey(fileId, userId)) : uploadsOf(fileId)[0];
4630
+ if (!u || chunkIndex < 0 || chunkIndex >= u.totalChunks) return;
4631
+ u.inFlight.delete(chunkIndex);
4632
+ if (!u.acked.has(chunkIndex)) {
4633
+ u.acked.add(chunkIndex);
4634
+ u.lastAckAt = Date.now();
4635
+ reportUpload(u, u.acked.size >= u.totalChunks);
4636
+ }
4637
+ pumpUpload(u);
4638
+ }
4639
+ function dropRelayDownload(fileId, removePart) {
4640
+ const d = relayDownloads.get(fileId);
4641
+ relayDownloads.delete(fileId);
4642
+ if (d) {
4643
+ try {
4644
+ fs8.closeSync(d.fd);
4645
+ } catch {
4646
+ }
4647
+ }
4648
+ if (removePart) {
4649
+ try {
4650
+ fs8.rmSync(partPathOf(fileId), { force: true });
4651
+ } catch {
4652
+ }
4526
4653
  }
4527
4654
  }
4528
4655
  async function handleIncomingChunk(payload) {
4529
4656
  const { fileId, chunkIndex, totalChunks, from } = payload;
4530
4657
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4531
- if (!tx) return;
4658
+ if (!tx || tx.direction !== "download" || tx.status === "cancelled") return;
4659
+ if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || chunkIndex < 0 || chunkIndex >= totalChunks) return;
4660
+ const ack = () => wsConn?.send(JSON.stringify({ type: "file_chunk_ack", payload: { fileId, chunkIndex, status: "received" } }));
4661
+ if (tx.status === "completed" || tx.assembling) {
4662
+ ack();
4663
+ return;
4664
+ }
4532
4665
  const mode = payload.enc === void 0 || payload.enc === null ? "plain" : String(payload.enc);
4533
4666
  if (tx.relayMode && tx.relayMode !== mode) throw new Error(`transfer ${fileId} mixed sealed and plaintext chunks`);
4534
4667
  if (!tx.relayMode) console.log(`[DAEMON] Relay transfer ${fileId}: ${mode}`);
4535
4668
  tx.relayMode = mode;
4536
4669
  const chunkBuffer = await openRelayChunk(payload);
4670
+ if (tx.status === "completed" || tx.assembling || tx.status === "cancelled") {
4671
+ ack();
4672
+ return;
4673
+ }
4537
4674
  tx.status = "downloading";
4538
4675
  tx.totalChunks = totalChunks;
4539
4676
  tx.peerId = from;
4540
- const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${chunkIndex}`);
4541
- const duplicate = fs8.existsSync(chunkPath);
4542
- if (!duplicate) {
4677
+ let d = relayDownloads.get(fileId);
4678
+ if (!d) {
4679
+ fs8.mkdirSync(TEMP_DIR, { recursive: true });
4680
+ const partPath = partPathOf(fileId);
4681
+ d = { fd: fs8.openSync(partPath, "w+"), partPath, got: /* @__PURE__ */ new Set(), totalChunks, lastStateAt: 0 };
4682
+ relayDownloads.set(fileId, d);
4683
+ }
4684
+ const isLast = chunkIndex === totalChunks - 1;
4685
+ const at = isLast && tx.size > 0 ? tx.size - chunkBuffer.length : chunkIndex * chunkBuffer.length;
4686
+ if (at < 0 || tx.size > 0 && at + chunkBuffer.length > tx.size) throw new Error(`relay chunk ${chunkIndex} of ${fileId} does not fit the file`);
4687
+ if (!d.got.has(chunkIndex)) {
4688
+ let written = 0;
4689
+ while (written < chunkBuffer.length) written += fs8.writeSync(d.fd, chunkBuffer, written, chunkBuffer.length - written, at + written);
4690
+ d.got.add(chunkIndex);
4543
4691
  tx.bytesTransferred += chunkBuffer.length;
4544
- tx.chunksCount++;
4692
+ tx.chunksCount = d.got.size;
4545
4693
  }
4546
- fs8.writeFileSync(chunkPath, chunkBuffer);
4547
- tx.progress = tx.bytesTransferred / tx.size * 100;
4694
+ tx.progress = tx.size > 0 ? Math.min(100, tx.bytesTransferred / tx.size * 100) : 100;
4548
4695
  const elapsed = (Date.now() - tx.startTime) / 1e3;
4549
4696
  if (elapsed > 0) {
4550
4697
  tx.speedKBps = tx.bytesTransferred / 1024 / elapsed;
4551
- tx.etaSeconds = (tx.size - tx.bytesTransferred) / (tx.speedKBps * 1024);
4698
+ tx.etaSeconds = tx.speedKBps > 0 ? (tx.size - tx.bytesTransferred) / (tx.speedKBps * 1024) : 0;
4552
4699
  }
4553
- writeStateFile();
4554
- broadcastSSE("transfer_progress", {
4555
- fileId,
4556
- fileName: tx.name,
4557
- size: tx.size,
4558
- bytesTransferred: tx.bytesTransferred,
4559
- progress: tx.progress,
4560
- speedBytesPerSecond: tx.speedKBps * 1024,
4561
- timeRemainingSeconds: tx.etaSeconds,
4562
- status: "downloading"
4563
- });
4564
- wsConn?.send(JSON.stringify({
4565
- type: "file_chunk_ack",
4566
- payload: { fileId, chunkIndex, status: "received" }
4567
- }));
4568
- if (!duplicate && totalChunks > 0 && tx.chunksCount >= totalChunks) {
4569
- setTimeout(() => {
4570
- if (tx.status === "downloading") handleFileComplete({ fileId });
4571
- }, 5e3).unref?.();
4700
+ const done = d.got.size >= totalChunks;
4701
+ const now = Date.now();
4702
+ if (done || now - d.lastStateAt >= 250) {
4703
+ d.lastStateAt = now;
4704
+ writeStateFile();
4705
+ broadcastSSE("transfer_progress", {
4706
+ fileId,
4707
+ fileName: tx.name,
4708
+ size: tx.size,
4709
+ bytesTransferred: tx.bytesTransferred,
4710
+ progress: tx.progress,
4711
+ speedBytesPerSecond: tx.speedKBps * 1024,
4712
+ timeRemainingSeconds: tx.etaSeconds,
4713
+ status: "downloading"
4714
+ });
4572
4715
  }
4716
+ ack();
4717
+ if (done) handleFileComplete({ fileId });
4573
4718
  }
4574
4719
  function handleFileComplete(payload) {
4575
4720
  const { fileId } = payload;
4576
4721
  const tx = activeTransfers.find((t) => t.fileId === fileId);
4577
- if (!tx || tx.assembling || tx.status === "completed") return;
4722
+ if (!tx || tx.direction !== "download" || tx.assembling || tx.status === "completed") return;
4723
+ const d = relayDownloads.get(fileId);
4724
+ if (tx.size > 0 && (!d || d.got.size < d.totalChunks)) return;
4578
4725
  tx.assembling = true;
4579
- const destDir = tx.saveDir || process.cwd();
4580
- const destPath = path7.join(destDir, tx.name);
4581
- try {
4582
- if (!fs8.existsSync(destDir)) {
4583
- fs8.mkdirSync(destDir, { recursive: true });
4584
- }
4585
- } catch (err) {
4586
- console.error(`[DAEMON] Failed to create destination directory ${destDir}:`, err);
4587
- tx.status = "error";
4588
- writeStateFile();
4589
- return;
4590
- }
4591
- const writeStream = fs8.createWriteStream(destPath);
4592
- writeStream.on("error", (err) => {
4593
- console.error(`[DAEMON] Write stream error for ${destPath}:`, err);
4726
+ const fail = (err) => {
4727
+ console.error(`[DAEMON] Could not save ${tx.name}:`, err?.message || err);
4594
4728
  tx.status = "error";
4595
4729
  writeStateFile();
4596
4730
  broadcastSSE("transfer_progress", {
@@ -4603,40 +4737,44 @@ function handleFileComplete(payload) {
4603
4737
  timeRemainingSeconds: 0,
4604
4738
  status: "error"
4605
4739
  });
4606
- });
4607
- writeStream.on("finish", () => {
4608
- console.log(`[DAEMON] File successfully downloaded and assembled: ${destPath}`);
4609
- tx.status = "completed";
4610
- tx.progress = 100;
4611
- tx.speedKBps = 0;
4612
- tx.etaSeconds = 0;
4613
- writeStateFile();
4614
- broadcastSSE("transfer_progress", {
4615
- fileId,
4616
- fileName: tx.name,
4617
- size: tx.size,
4618
- bytesTransferred: tx.size,
4619
- progress: 100,
4620
- speedBytesPerSecond: 0,
4621
- timeRemainingSeconds: 0,
4622
- status: "completed"
4623
- });
4624
- });
4625
- for (let i = 0; i < tx.totalChunks; i++) {
4626
- const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${i}`);
4627
- if (fs8.existsSync(chunkPath)) {
4740
+ };
4741
+ const destDir = tx.saveDir || process.cwd();
4742
+ const destPath = path7.join(destDir, tx.name);
4743
+ try {
4744
+ fs8.mkdirSync(destDir, { recursive: true });
4745
+ if (d) {
4746
+ fs8.fsyncSync(d.fd);
4747
+ dropRelayDownload(fileId, false);
4628
4748
  try {
4629
- const chunkBuf = fs8.readFileSync(chunkPath);
4630
- writeStream.write(chunkBuf);
4631
- fs8.unlinkSync(chunkPath);
4632
- } catch (err) {
4633
- console.error(`[DAEMON] Error reading/writing chunk ${i}:`, err);
4634
- writeStream.emit("error", err);
4635
- return;
4749
+ fs8.renameSync(d.partPath, destPath);
4750
+ } catch {
4751
+ fs8.copyFileSync(d.partPath, destPath);
4752
+ fs8.rmSync(d.partPath, { force: true });
4636
4753
  }
4754
+ } else {
4755
+ fs8.writeFileSync(destPath, Buffer.alloc(0));
4637
4756
  }
4757
+ } catch (err) {
4758
+ fail(err);
4759
+ return;
4638
4760
  }
4639
- writeStream.end();
4761
+ console.log(`[DAEMON] File successfully downloaded: ${destPath}`);
4762
+ tx.status = "completed";
4763
+ tx.progress = 100;
4764
+ tx.bytesTransferred = tx.size;
4765
+ tx.speedKBps = 0;
4766
+ tx.etaSeconds = 0;
4767
+ writeStateFile();
4768
+ broadcastSSE("transfer_progress", {
4769
+ fileId,
4770
+ fileName: tx.name,
4771
+ size: tx.size,
4772
+ bytesTransferred: tx.size,
4773
+ progress: 100,
4774
+ speedBytesPerSecond: 0,
4775
+ timeRemainingSeconds: 0,
4776
+ status: "completed"
4777
+ });
4640
4778
  }
4641
4779
  async function waitSignalReady(maxMs = 8e3) {
4642
4780
  if (!session.id) return { status: 409, body: { success: false, error: "No active session. Call /session/start first." } };
@@ -5113,7 +5251,7 @@ function embeddedDispatch(method, pathname, body) {
5113
5251
  }
5114
5252
  });
5115
5253
  }
5116
- var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, DAEMON_LOG_MAX, openLog, logBytes, DAEMON_LOG_MAX_AGE_MS, pruneOldLogs, logStream, logRotating, logPending, writeLogLine, logMessage, fmtLogArg, crypto6, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, participants, sseClients, wsConn, signalingMode, wsFailures, httpModeSince, WS_RETRY_AFTER_MS, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, TMP_CHUNK_MAX_AGE_MS, TMP_FILE_MAX_AGE_MS, KDF_ITERATIONS, IV_LEN2, RELAY_ENC, relayKeyCache, keyring, relayReceivers, relaySenders, PUBLIC_TRACKERS, STATE_STALE_MS, activeUploads, RELAY_ACK_TIMEOUT_MS, RELAY_MAX_RETRIES, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
5254
+ var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, DAEMON_LOG_MAX, openLog, logBytes, DAEMON_LOG_MAX_AGE_MS, pruneOldLogs, logStream, logRotating, logPending, writeLogLine, logMessage, fmtLogArg, crypto6, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, participants, sseClients, wsConn, signalingMode, wsFailures, httpModeSince, WS_RETRY_AFTER_MS, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, TMP_CHUNK_MAX_AGE_MS, TMP_FILE_MAX_AGE_MS, KDF_ITERATIONS, IV_LEN2, RELAY_ENC, relayKeyCache, keyring, relayReceivers, relaySenders, PUBLIC_TRACKERS, STATE_STALE_MS, activeUploads, uploadKey, uploadsOf, RELAY_ACK_TIMEOUT_MS, RELAY_STALL_MS, RELAY_WINDOW, RELAY_LEGACY_CHUNK, RELAY_FAST_CHUNK, RELAY_FORMAT, RELAY_SOCKET_HIGH_BYTES, relayDownloads, partPathOf, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
5117
5255
  var init_daemon = __esm({
5118
5256
  "../../cli/daemon.ts"() {
5119
5257
  init_mcp();
@@ -5294,8 +5432,17 @@ var init_daemon = __esm({
5294
5432
  STATE_STALE_MS = 24 * 60 * 60 * 1e3;
5295
5433
  pruneStaleStateOnStartup();
5296
5434
  activeUploads = /* @__PURE__ */ new Map();
5435
+ uploadKey = (fileId, userId) => `${fileId}|${userId}`;
5436
+ uploadsOf = (fileId) => [...activeUploads.values()].filter((u) => u.fileId === fileId);
5297
5437
  RELAY_ACK_TIMEOUT_MS = Number(process.env.SRIFT_RELAY_ACK_TIMEOUT_MS) || 15e3;
5298
- RELAY_MAX_RETRIES = 40;
5438
+ RELAY_STALL_MS = RELAY_ACK_TIMEOUT_MS * 40;
5439
+ RELAY_WINDOW = 16;
5440
+ RELAY_LEGACY_CHUNK = 64 * 1024;
5441
+ RELAY_FAST_CHUNK = 256 * 1024;
5442
+ RELAY_FORMAT = "b64";
5443
+ RELAY_SOCKET_HIGH_BYTES = 8 * 1024 * 1024;
5444
+ relayDownloads = /* @__PURE__ */ new Map();
5445
+ partPathOf = (fileId) => path7.join(TEMP_DIR, `${fileId.replace(/[^A-Za-z0-9_.-]/g, "_")}_chunk_part`);
5299
5446
  app = express();
5300
5447
  LOCAL_HOSTS = /* @__PURE__ */ new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`, `[::1]:${PORT}`]);
5301
5448
  app.use((req, res, next) => {
@@ -5311,7 +5458,7 @@ var init_daemon = __esm({
5311
5458
  app.use(cors({ origin: "*", maxAge: 600 }));
5312
5459
  app.use(express.json());
5313
5460
  DAEMON_START_TIME = Date.now();
5314
- PACKAGE_VERSION = "4.3.0";
5461
+ PACKAGE_VERSION = "4.3.2";
5315
5462
  app.get("/health", (req, res) => {
5316
5463
  res.json({
5317
5464
  ok: true,
@@ -5602,7 +5749,7 @@ var init_daemon = __esm({
5602
5749
  }
5603
5750
  wsConn?.send(JSON.stringify({
5604
5751
  type: "file_accept",
5605
- payload: { fileId, relayEnc: RELAY_ENC, ...relayPk ? { relayPk } : {} }
5752
+ payload: { fileId, relayEnc: RELAY_ENC, relayFmt: RELAY_FORMAT, ...relayPk ? { relayPk } : {} }
5606
5753
  }));
5607
5754
  res.json({ success: true });
5608
5755
  });
@@ -5963,7 +6110,7 @@ data: /mcp/messages
5963
6110
  wtClient = null;
5964
6111
  }
5965
6112
  activeTorrents.clear();
5966
- activeUploads.clear();
6113
+ for (const u of [...activeUploads.values()]) stopUpload(u);
5967
6114
  session = { id: null, name: null, role: null, isConnected: false, userId: null };
5968
6115
  activeTransfers = [];
5969
6116
  chatHistory = [];
@@ -6026,7 +6173,7 @@ data: /mcp/messages
6026
6173
  }
6027
6174
  try {
6028
6175
  activeTorrents.clear();
6029
- activeUploads.clear();
6176
+ for (const u of [...activeUploads.values()]) stopUpload(u);
6030
6177
  } catch {
6031
6178
  }
6032
6179
  try {