srift-transfer 2.2.12

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/daemon.js ADDED
@@ -0,0 +1,2259 @@
1
+ #!/usr/bin/env node
2
+ // ../../cli/daemon.ts
3
+ import express from "express";
4
+ import cors from "cors";
5
+ import { WebSocket } from "ws";
6
+ import { webcrypto } from "crypto";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { v4 as uuidv4 } from "uuid";
10
+ import dotenv from "dotenv";
11
+
12
+ // ../../cli/mcp.ts
13
+ import http from "http";
14
+
15
+ // ../../lib/mcp/core.mjs
16
+ var PROTOCOL_VERSION = "2025-06-18";
17
+ var MCP_TOOLS = [
18
+ {
19
+ name: "srift_start_session",
20
+ description: "Create a new SRIFT secure session. Returns the 7-character session ID and a shareable URL. The host approves all future joins. E2EE keys derive locally from the session ID + optional roomSecret.",
21
+ inputSchema: {
22
+ type: "object",
23
+ properties: {
24
+ sessionName: { type: "string", description: "Human-readable session name (optional)" },
25
+ roomSecret: { type: "string", description: "Optional shared secret for extra-strong E2EE key derivation. Never sent to server." }
26
+ }
27
+ }
28
+ },
29
+ {
30
+ name: "srift_join_session",
31
+ description: "Request to join an existing SRIFT session by its 7-character session ID. The host must approve before transfers/chat work.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ sessionId: { type: "string", description: "7-character session ID (e.g. ABC1234)" },
36
+ username: { type: "string", description: "Display name (optional, defaults to AI-Agent)" },
37
+ roomSecret: { type: "string", description: "Optional shared secret matching the host's" }
38
+ },
39
+ required: ["sessionId"]
40
+ }
41
+ },
42
+ {
43
+ name: "srift_session_status",
44
+ description: "Get current session details: id, role (host/guest), connection state, pending join requests, peer count.",
45
+ inputSchema: { type: "object", properties: {} }
46
+ },
47
+ {
48
+ name: "srift_close_session",
49
+ description: "Close the active session. Flushes E2EE keys, disconnects signaling, clears chat history.",
50
+ inputSchema: { type: "object", properties: {} }
51
+ },
52
+ {
53
+ name: "srift_approve_join",
54
+ description: "Host-only. Approve a pending guest join request by their temp user ID.",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ tempUserId: { type: "string", description: "Temp user ID from session_status pendingJoins" }
59
+ },
60
+ required: ["tempUserId"]
61
+ }
62
+ },
63
+ {
64
+ name: "srift_reject_join",
65
+ description: "Host-only. Reject a pending guest join request.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ tempUserId: { type: "string" },
70
+ reason: { type: "string", description: "Optional rejection reason" }
71
+ },
72
+ required: ["tempUserId"]
73
+ }
74
+ },
75
+ {
76
+ name: "srift_kick_user",
77
+ description: "Host-only. Disconnect and ban a peer from the active room.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: { userId: { type: "string" } },
81
+ required: ["userId"]
82
+ }
83
+ },
84
+ {
85
+ name: "srift_send_file",
86
+ 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.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ filePath: { type: "string", description: "Absolute path to the file on disk" }
91
+ },
92
+ required: ["filePath"]
93
+ }
94
+ },
95
+ {
96
+ name: "srift_accept_transfer",
97
+ description: "Accept a pending file offer from the peer and start downloading.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ fileId: { type: "string", description: "fileId from the inbound file_offer event" },
102
+ saveDir: { type: "string", description: "Absolute path to destination directory (defaults to CWD)" }
103
+ },
104
+ required: ["fileId"]
105
+ }
106
+ },
107
+ {
108
+ name: "srift_list_transfers",
109
+ description: "List all active and recent transfers with progress, speed, ETA, and status.",
110
+ inputSchema: { type: "object", properties: {} }
111
+ },
112
+ {
113
+ name: "srift_quick_share",
114
+ description: 'One-shot helper: if no session is active, creates one; then seeds the file and returns a public DOWNLOAD URL (https://srift.app/d/<token>) the recipient can open in any browser or curl. No install needed on their side. Best for "AI wants to deliver a file to the user with zero ceremony".',
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ filePath: { type: "string" },
119
+ sessionName: { type: "string" }
120
+ },
121
+ required: ["filePath"]
122
+ }
123
+ },
124
+ {
125
+ name: "srift_send_chat",
126
+ description: "Send an end-to-end encrypted (AES-256-GCM) chat message to the connected peer.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: { message: { type: "string" } },
130
+ required: ["message"]
131
+ }
132
+ },
133
+ {
134
+ name: "srift_chat_history",
135
+ description: "Read the locally-decrypted chat log for the current session.",
136
+ inputSchema: { type: "object", properties: {} }
137
+ },
138
+ {
139
+ name: "srift_read_state",
140
+ description: "Return the raw .srift-state.json contents (session + active transfers snapshot). Useful for one-shot polling without subscribing to SSE.",
141
+ inputSchema: { type: "object", properties: {} }
142
+ }
143
+ ];
144
+ var MCP_RESOURCES = [
145
+ {
146
+ uri: "srift://session/status",
147
+ name: "Session Status",
148
+ description: "Current session ID, role, connection status, pending joins",
149
+ mimeType: "application/json"
150
+ },
151
+ {
152
+ uri: "srift://transfers/active",
153
+ name: "Active Transfers",
154
+ description: "Uploads and downloads with live progress, speed, ETA",
155
+ mimeType: "application/json"
156
+ },
157
+ {
158
+ uri: "srift://chat/messages",
159
+ name: "Chat History",
160
+ description: "E2EE chat log decrypted locally",
161
+ mimeType: "application/json"
162
+ },
163
+ {
164
+ uri: "srift://workspace/state",
165
+ name: "Workspace State Snapshot",
166
+ description: "Raw .srift-state.json contents",
167
+ mimeType: "application/json"
168
+ },
169
+ {
170
+ uri: "srift://docs/quickstart",
171
+ name: "Quickstart Instructions",
172
+ description: "Inline guide for AI agents on how to use this MCP server",
173
+ mimeType: "text/markdown"
174
+ }
175
+ ];
176
+ var MCP_PROMPTS = [
177
+ {
178
+ name: "send_file_to_user",
179
+ description: "Workflow: create a session, seed a file, hand the user a share URL.",
180
+ arguments: [
181
+ { name: "filePath", description: "Absolute path to the file you want to deliver", required: true }
182
+ ]
183
+ },
184
+ {
185
+ name: "receive_file_from_user",
186
+ description: "Workflow: open a session and wait for the user to drop files into it.",
187
+ arguments: [
188
+ { name: "saveDir", description: "Where to save incoming files", required: false }
189
+ ]
190
+ },
191
+ {
192
+ name: "start_collab_session",
193
+ description: "Workflow: open a long-lived E2EE chat + file room with the user.",
194
+ arguments: [
195
+ { name: "sessionName", description: "Label for the room", required: false }
196
+ ]
197
+ }
198
+ ];
199
+ function buildPromptMessages(name, args) {
200
+ switch (name) {
201
+ case "send_file_to_user":
202
+ return [{
203
+ role: "user",
204
+ content: {
205
+ type: "text",
206
+ text: `Deliver the file at "${args?.filePath || "<filePath>"}" to the user via SRIFT.
207
+
208
+ 1) Call srift_quick_share with filePath="${args?.filePath || "<filePath>"}".
209
+ 2) Read the returned share URL aloud to the user.
210
+ 3) Poll srift_list_transfers (or read srift://transfers/active) until status is "completed".`
211
+ }
212
+ }];
213
+ case "receive_file_from_user":
214
+ return [{
215
+ role: "user",
216
+ content: {
217
+ type: "text",
218
+ text: `Open a SRIFT room and wait for the user to send you files.
219
+
220
+ 1) Call srift_start_session.
221
+ 2) Give the user the join URL: https://srift.app/join-session?id=<SESSION_ID> (full session UI with chat + multi-file transfer).
222
+ Or, for a single one-shot file from agent \u2192 user, prefer srift_quick_share which returns a direct download URL.
223
+ 3) Poll srift_session_status; when pendingJoins appears, call srift_approve_join.
224
+ 4) When a file_offer arrives (visible via srift_list_transfers), call srift_accept_transfer with saveDir="${args?.saveDir || "./"}".`
225
+ }
226
+ }];
227
+ case "start_collab_session":
228
+ return [{
229
+ role: "user",
230
+ content: {
231
+ type: "text",
232
+ text: `Open a SRIFT collaboration room named "${args?.sessionName || "AI-Collab"}" and use it for E2EE chat + ad-hoc file transfer with the user.
233
+
234
+ 1) srift_start_session({ sessionName: "${args?.sessionName || "AI-Collab"}" })
235
+ 2) Share the join URL.
236
+ 3) If srift_send_chat / srift_send_file are available on this transport, use them directly. Otherwise (hosted/remote MCP), tell the user to open the join URL in their browser or CLI \u2014 chat and file transfer happen E2EE between the real peers, never through this server.
237
+ 4) Use srift_accept_transfer as needed when running locally.`
238
+ }
239
+ }];
240
+ }
241
+ return [];
242
+ }
243
+ var QUICKSTART_DOC = `# SRIFT MCP Quickstart for AI Agents
244
+
245
+ You are connected to SRIFT \u2014 a zero-config, zero-token, peer-to-peer secure file transfer + E2EE chat layer.
246
+
247
+ ## Common workflows
248
+
249
+ **Send a file to the user (fastest path \u2014 zero install on their side):**
250
+ \`\`\`
251
+ srift_quick_share({ filePath: "/abs/path/file.zip" })
252
+ \u2192 returns { downloadUrl, fileName, fileSize }
253
+ \u2192 paste downloadUrl to the user (https://srift.app/d/<token>)
254
+ \u2192 they open it in any browser, or run:
255
+ curl -OJ <downloadUrl>
256
+ wget --content-disposition <downloadUrl>
257
+ \u2192 keep the daemon running while the download is in progress
258
+ \`\`\`
259
+
260
+ **Open a long-lived collaboration room:**
261
+ \`\`\`
262
+ srift_start_session({ sessionName: "AI-Collab" })
263
+ \u2192 returns { sessionId }
264
+ \u2192 user joins via https://srift.app/join-session?id=<sessionId>
265
+ \u2192 host (you) calls srift_approve_join when pendingJoins appears
266
+ \`\`\`
267
+
268
+ **Receive a file from the user:**
269
+ The user clicks the share link, requests join, you approve, they drop a file,
270
+ you see it via srift_list_transfers and call srift_accept_transfer.
271
+
272
+ ## Monitoring
273
+ - Poll \`srift_session_status\` or read resource \`srift://session/status\`.
274
+ - Poll \`srift_list_transfers\` or read \`srift://transfers/active\`.
275
+ - Live state snapshot: \`srift_read_state\` or \`srift://workspace/state\` (local daemon only).
276
+
277
+ ## Security model
278
+ - AES-256-GCM, 12-byte random IV per message.
279
+ - PBKDF2-SHA256, 100,000 iterations.
280
+ - All keys derive locally from sessionId (+ optional roomSecret). Server never sees them.
281
+ - File chunks transit P2P (WebTorrent for >10MB, WebSocket-relay fallback otherwise).
282
+
283
+ ## Local daemon vs hosted MCP
284
+ If you are talking to the hosted endpoint (https://srift.app/mcp) instead of a local
285
+ \`srift mcp\` daemon, file-transfer tools that require a local filesystem
286
+ (srift_send_file, srift_accept_transfer, srift_quick_share) and E2EE chat tools
287
+ (srift_send_chat, srift_chat_history) are NOT available \u2014 the hosted server
288
+ never touches plaintext file bytes or derives E2EE keys, only session/peer
289
+ orchestration. Install the CLI (\`curl -fsSL https://srift.app/install.sh | sh\`)
290
+ for full file-transfer + chat capability.
291
+ `;
292
+ function resp(id, result) {
293
+ return { jsonrpc: "2.0", id, result };
294
+ }
295
+ function errResp(id, code, message) {
296
+ return { jsonrpc: "2.0", id, error: { code, message } };
297
+ }
298
+ function createMcpHandler(opts) {
299
+ const { backend, serverInfo, instructions } = opts;
300
+ const tools = opts.toolNames ? MCP_TOOLS.filter((t) => opts.toolNames.has(t.name)) : MCP_TOOLS;
301
+ const resources = opts.resourceUris ? MCP_RESOURCES.filter((r) => opts.resourceUris.has(r.uri)) : MCP_RESOURCES;
302
+ const prompts = opts.promptNames ? MCP_PROMPTS.filter((p) => opts.promptNames.has(p.name)) : MCP_PROMPTS;
303
+ const toolSet = new Set(tools.map((t) => t.name));
304
+ const resourceSet = new Set(resources.map((r) => r.uri));
305
+ const promptSet = new Set(prompts.map((p) => p.name));
306
+ return async function handleMcpMessage2(message, ctx = {}) {
307
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
308
+ return errResp(null, -32600, "Invalid Request");
309
+ }
310
+ const { id, method, params } = message;
311
+ if (id === void 0 || id === null) {
312
+ return null;
313
+ }
314
+ if (typeof method !== "string") {
315
+ return errResp(id, -32600, "Invalid Request: method must be a string");
316
+ }
317
+ try {
318
+ if (method === "initialize") {
319
+ return resp(id, {
320
+ protocolVersion: PROTOCOL_VERSION,
321
+ capabilities: {
322
+ tools: { listChanged: false },
323
+ resources: { listChanged: false, subscribe: false },
324
+ prompts: { listChanged: false },
325
+ logging: {}
326
+ },
327
+ serverInfo,
328
+ instructions
329
+ });
330
+ }
331
+ if (method === "ping") {
332
+ return resp(id, {});
333
+ }
334
+ if (method === "tools/list") {
335
+ return resp(id, { tools });
336
+ }
337
+ if (method === "tools/call") {
338
+ const { name, arguments: args = {} } = params || {};
339
+ if (typeof name !== "string") {
340
+ return errResp(id, -32602, "Invalid params: tool name required");
341
+ }
342
+ if (!toolSet.has(name)) {
343
+ return resp(id, {
344
+ isError: true,
345
+ content: [{ type: "text", text: `Tool "${name}" is not available on this transport.` }]
346
+ });
347
+ }
348
+ const text = await backend.callTool(name, args, ctx);
349
+ return resp(id, { content: [{ type: "text", text }] });
350
+ }
351
+ if (method === "resources/list") {
352
+ return resp(id, { resources });
353
+ }
354
+ if (method === "resources/read") {
355
+ const { uri } = params || {};
356
+ if (typeof uri !== "string" || !resourceSet.has(uri)) {
357
+ return errResp(id, -32602, `Resource not found: ${uri}`);
358
+ }
359
+ const content = await backend.readResource(uri, ctx);
360
+ return resp(id, {
361
+ contents: [{
362
+ uri,
363
+ mimeType: uri === "srift://docs/quickstart" ? "text/markdown" : "application/json",
364
+ text: content
365
+ }]
366
+ });
367
+ }
368
+ if (method === "prompts/list") {
369
+ return resp(id, { prompts });
370
+ }
371
+ if (method === "prompts/get") {
372
+ const { name, arguments: args = {} } = params || {};
373
+ if (typeof name !== "string" || !promptSet.has(name)) {
374
+ return errResp(id, -32602, `Prompt not found: ${name}`);
375
+ }
376
+ const messages = buildPromptMessages(name, args);
377
+ return resp(id, {
378
+ description: prompts.find((p) => p.name === name)?.description || "",
379
+ messages
380
+ });
381
+ }
382
+ if (method === "notifications/initialized" || method === "notifications/cancelled") {
383
+ return null;
384
+ }
385
+ return errResp(id, -32601, `Method not found: ${method}`);
386
+ } catch (err) {
387
+ if (method === "tools/call") {
388
+ return resp(id, { isError: true, content: [{ type: "text", text: `Error: ${err.message}` }] });
389
+ }
390
+ return errResp(id, -32603, err.message || "Internal error");
391
+ }
392
+ };
393
+ }
394
+
395
+ // ../../cli/mcp.ts
396
+ var DAEMON_PORT = parseInt(process.env.SRIFT_DAEMON_PORT || "3822", 10);
397
+ var DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
398
+ var SERVER_INFO = {
399
+ name: "srift-mcp-server",
400
+ title: "SRIFT Secure P2P File Transfer",
401
+ version: "2.0.0"
402
+ };
403
+ function callDaemon(endpoint, method, body) {
404
+ return new Promise((resolve, reject) => {
405
+ const url = `${DAEMON_URL}${endpoint}`;
406
+ const options = {
407
+ method,
408
+ headers: { "Content-Type": "application/json" }
409
+ };
410
+ const req = http.request(url, options, (res) => {
411
+ let data = "";
412
+ res.on("data", (chunk) => data += chunk);
413
+ res.on("end", () => {
414
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
415
+ try {
416
+ resolve(JSON.parse(data));
417
+ } catch {
418
+ resolve({ success: true });
419
+ }
420
+ } else {
421
+ try {
422
+ const errJson = JSON.parse(data);
423
+ reject(new Error(errJson.error || `HTTP ${res.statusCode}`));
424
+ } catch {
425
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
426
+ }
427
+ }
428
+ });
429
+ });
430
+ req.on("error", (err) => reject(new Error(`Daemon connection failed: ${err.message}`)));
431
+ if (body) req.write(JSON.stringify(body));
432
+ req.end();
433
+ });
434
+ }
435
+ async function callTool(name, args) {
436
+ switch (name) {
437
+ case "srift_start_session": {
438
+ const res = await callDaemon("/session/start", "POST", {
439
+ sessionName: args.sessionName,
440
+ roomSecret: args.roomSecret
441
+ });
442
+ return `Session created.
443
+ Session ID: ${res.sessionId}
444
+ Share URL: https://srift.app/join-session?id=${res.sessionId}
445
+ You are the HOST. Approve incoming joins with srift_approve_join.`;
446
+ }
447
+ case "srift_join_session": {
448
+ const res = await callDaemon("/session/join", "POST", args);
449
+ return `Join requested for session ${args.sessionId}. Status: ${res.success ? "awaiting host approval" : "failed"}.`;
450
+ }
451
+ case "srift_session_status": {
452
+ const res = await callDaemon("/status", "GET");
453
+ return JSON.stringify({ session: res.session, pendingJoins: res.pendingJoins }, null, 2);
454
+ }
455
+ case "srift_close_session":
456
+ await callDaemon("/session/close", "POST");
457
+ return "Session closed. Keys flushed.";
458
+ case "srift_approve_join":
459
+ await callDaemon("/session/approve", "POST", args);
460
+ return `Approved ${args.tempUserId}.`;
461
+ case "srift_reject_join":
462
+ await callDaemon("/session/reject", "POST", args);
463
+ return `Rejected ${args.tempUserId}.`;
464
+ case "srift_kick_user":
465
+ await callDaemon("/session/kick", "POST", args);
466
+ return `Kicked ${args.userId}.`;
467
+ case "srift_send_file": {
468
+ const res = await callDaemon("/send", "POST", args);
469
+ return `File offered.
470
+ File ID: ${res.fileId}
471
+ The peer must call srift_accept_transfer with this fileId.`;
472
+ }
473
+ case "srift_accept_transfer":
474
+ await callDaemon("/receive", "POST", args);
475
+ return `Transfer ${args.fileId} accepted. Downloading\u2026`;
476
+ case "srift_list_transfers": {
477
+ const res = await callDaemon("/status", "GET");
478
+ return JSON.stringify(res.activeTransfers || [], null, 2);
479
+ }
480
+ case "srift_quick_share": {
481
+ const res = await callDaemon("/quick-share", "POST", args);
482
+ const url = res.downloadUrl || res.shareUrl;
483
+ return [
484
+ `Quick share ready.`,
485
+ `Download URL: ${url}`,
486
+ `File: ${res.fileName} (${res.fileSize} bytes)`,
487
+ ``,
488
+ `Give the URL to the user \u2014 they open it in any browser, or run:`,
489
+ ` curl -OJ "${url}"`,
490
+ `Keep this srift daemon running while they download.`
491
+ ].join("\n");
492
+ }
493
+ case "srift_send_chat":
494
+ await callDaemon("/chat/send", "POST", args);
495
+ return `Sent (E2EE): "${args.message}"`;
496
+ case "srift_chat_history": {
497
+ const res = await callDaemon("/chat/history", "GET");
498
+ return JSON.stringify(res, null, 2);
499
+ }
500
+ case "srift_read_state": {
501
+ const res = await callDaemon("/state", "GET");
502
+ return JSON.stringify(res, null, 2);
503
+ }
504
+ default:
505
+ throw new Error(`Unknown tool: ${name}`);
506
+ }
507
+ }
508
+ async function readResource(uri) {
509
+ if (uri === "srift://session/status") {
510
+ const res = await callDaemon("/status", "GET");
511
+ return JSON.stringify(res.session, null, 2);
512
+ }
513
+ if (uri === "srift://transfers/active") {
514
+ const res = await callDaemon("/status", "GET");
515
+ return JSON.stringify(res.activeTransfers || [], null, 2);
516
+ }
517
+ if (uri === "srift://chat/messages") {
518
+ const res = await callDaemon("/chat/history", "GET");
519
+ return JSON.stringify(res, null, 2);
520
+ }
521
+ if (uri === "srift://workspace/state") {
522
+ const res = await callDaemon("/state", "GET");
523
+ return JSON.stringify(res, null, 2);
524
+ }
525
+ if (uri === "srift://docs/quickstart") {
526
+ return QUICKSTART_DOC;
527
+ }
528
+ throw new Error(`Resource not found: ${uri}`);
529
+ }
530
+ var localDaemonBackend = { callTool, readResource };
531
+ var handleMcpMessage = createMcpHandler({
532
+ backend: localDaemonBackend,
533
+ serverInfo: SERVER_INFO,
534
+ 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."
535
+ });
536
+
537
+ // ../../cli/daemon.ts
538
+ var _WebTorrentCtor = null;
539
+ var _webTorrentLoadError = null;
540
+ async function loadWebTorrent() {
541
+ if (_WebTorrentCtor) return _WebTorrentCtor;
542
+ if (_webTorrentLoadError) throw new Error(_webTorrentLoadError);
543
+ try {
544
+ const mod = await import("webtorrent");
545
+ _WebTorrentCtor = mod.default || mod;
546
+ return _WebTorrentCtor;
547
+ } catch (err) {
548
+ _webTorrentLoadError = `WebTorrent unavailable (likely missing native module): ${err?.message || err}`;
549
+ console.error("[DAEMON]", _webTorrentLoadError);
550
+ throw new Error(_webTorrentLoadError);
551
+ }
552
+ }
553
+ var logPath = path.join(process.cwd(), ".srift-daemon.log");
554
+ var logStream = fs.createWriteStream(logPath, { flags: "a" });
555
+ var logMessage = (level, message) => {
556
+ logStream.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${message}
557
+ `);
558
+ };
559
+ console.log = (...args) => {
560
+ logMessage("INFO", args.map((arg) => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" "));
561
+ };
562
+ console.error = (...args) => {
563
+ logMessage("ERROR", args.map((arg) => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" "));
564
+ };
565
+ console.warn = (...args) => {
566
+ logMessage("WARN", args.map((arg) => typeof arg === "object" ? JSON.stringify(arg) : arg).join(" "));
567
+ };
568
+ var crypto = globalThis.crypto || webcrypto;
569
+ dotenv.config({ path: ".env.local" });
570
+ dotenv.config();
571
+ process.on("uncaughtException", (err) => {
572
+ console.error("[DAEMON] Uncaught Exception:", err);
573
+ });
574
+ process.on("unhandledRejection", (reason, promise) => {
575
+ console.error("[DAEMON] Unhandled Rejection at:", promise, "reason:", reason);
576
+ });
577
+ var PORT = parseInt(process.env.SRIFT_DAEMON_PORT || "3822", 10);
578
+ var DEFAULT_SIGNALER_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080";
579
+ var activeSignalerUrl = DEFAULT_SIGNALER_URL;
580
+ async function selectSignalerUrl() {
581
+ if (DEFAULT_SIGNALER_URL.includes("127.0.0.1") || DEFAULT_SIGNALER_URL.includes("localhost")) {
582
+ try {
583
+ const controller = new AbortController();
584
+ const id = setTimeout(() => controller.abort(), 1e3);
585
+ await fetch(DEFAULT_SIGNALER_URL, { signal: controller.signal });
586
+ clearTimeout(id);
587
+ console.log(`[DAEMON] Local signaler detected at ${DEFAULT_SIGNALER_URL}`);
588
+ } catch (e) {
589
+ console.log(`[DAEMON] Local signaler at ${DEFAULT_SIGNALER_URL} is unreachable. Falling back to production signaler: https://srift.app`);
590
+ activeSignalerUrl = "https://srift.app";
591
+ }
592
+ }
593
+ }
594
+ var session = {
595
+ id: null,
596
+ name: null,
597
+ role: null,
598
+ isConnected: false,
599
+ userId: null
600
+ };
601
+ var activeTransfers = [];
602
+ var chatHistory = [];
603
+ var pendingJoins = [];
604
+ var sseClients = [];
605
+ var wsConn = null;
606
+ var wsUrl = null;
607
+ var csrfToken = null;
608
+ var heartbeatInterval = null;
609
+ var lastHeartbeatAckAt = 0;
610
+ var encryptionKey = null;
611
+ var sessionTerminated = false;
612
+ var terminationReason = null;
613
+ var activeTorrents = /* @__PURE__ */ new Map();
614
+ var wtClient = null;
615
+ var pubshares = /* @__PURE__ */ new Map();
616
+ var pubsharesByToken = /* @__PURE__ */ new Map();
617
+ var activePulls = /* @__PURE__ */ new Map();
618
+ var pubshareRegResolvers = /* @__PURE__ */ new Map();
619
+ async function getWtClient() {
620
+ if (!wtClient) {
621
+ const WTCtor = await loadWebTorrent();
622
+ wtClient = new WTCtor();
623
+ }
624
+ return wtClient;
625
+ }
626
+ var TEMP_DIR = path.join(process.cwd(), ".srift-temp");
627
+ if (!fs.existsSync(TEMP_DIR)) {
628
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
629
+ }
630
+ var KDF_ITERATIONS = 1e5;
631
+ var IV_LEN = 12;
632
+ function bytesToBase64(bytes) {
633
+ let binary = "";
634
+ const CH = 32768;
635
+ for (let i = 0; i < bytes.length; i += CH) {
636
+ binary += String.fromCharCode.apply(
637
+ null,
638
+ Array.from(bytes.subarray(i, i + CH))
639
+ );
640
+ }
641
+ return Buffer.from(binary, "binary").toString("base64");
642
+ }
643
+ function base64ToBytes(b64) {
644
+ const binary = Buffer.from(b64, "base64").toString("binary");
645
+ const out = new Uint8Array(binary.length);
646
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
647
+ return out;
648
+ }
649
+ async function deriveKey(sessionId, roomSecret) {
650
+ const encoder = new TextEncoder();
651
+ const salt = encoder.encode(
652
+ "srift-salt-" + sessionId + (roomSecret ? "|rs|" + roomSecret : "")
653
+ );
654
+ const ikm = encoder.encode(sessionId + (roomSecret ? "::" + roomSecret : ""));
655
+ const keyMaterial = await crypto.subtle.importKey(
656
+ "raw",
657
+ ikm,
658
+ { name: "PBKDF2" },
659
+ false,
660
+ ["deriveBits", "deriveKey"]
661
+ );
662
+ return crypto.subtle.deriveKey(
663
+ { name: "PBKDF2", salt, iterations: KDF_ITERATIONS, hash: "SHA-256" },
664
+ keyMaterial,
665
+ { name: "AES-GCM", length: 256 },
666
+ false,
667
+ ["encrypt", "decrypt"]
668
+ );
669
+ }
670
+ async function encrypt(message) {
671
+ if (!encryptionKey) throw new Error("Key not derived");
672
+ const data = new TextEncoder().encode(message);
673
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
674
+ const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, encryptionKey, data);
675
+ const ct = new Uint8Array(encrypted);
676
+ const combined = new Uint8Array(iv.length + ct.length);
677
+ combined.set(iv);
678
+ combined.set(ct, iv.length);
679
+ return bytesToBase64(combined);
680
+ }
681
+ async function decrypt(encB64) {
682
+ if (!encryptionKey) throw new Error("Key not derived");
683
+ const combined = base64ToBytes(encB64);
684
+ const iv = combined.slice(0, IV_LEN);
685
+ const ciphertext = combined.slice(IV_LEN);
686
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, encryptionKey, ciphertext);
687
+ return new TextDecoder().decode(decrypted);
688
+ }
689
+ function getTrackers(sessionId) {
690
+ const list = [];
691
+ try {
692
+ const signalerUrl = wsUrl || activeSignalerUrl;
693
+ const trackerUrl = signalerUrl.replace(/^http/, "ws").replace(/\/ws$/, "/announce");
694
+ if (trackerUrl) list.push(trackerUrl);
695
+ } catch {
696
+ }
697
+ list.push("wss://tracker.webtorrent.dev");
698
+ return list;
699
+ }
700
+ function writeStateFile() {
701
+ const statePath = path.join(process.cwd(), ".srift-state.json");
702
+ const stateData = {
703
+ session: {
704
+ id: session.id,
705
+ name: session.name,
706
+ role: session.role,
707
+ isConnected: session.isConnected,
708
+ peerCount: session.role ? 1 : 0
709
+ // simplified peer calculation
710
+ },
711
+ activeTransfers: activeTransfers.map((t) => ({
712
+ fileId: t.fileId,
713
+ name: t.name,
714
+ size: t.size,
715
+ progress: parseFloat(t.progress.toFixed(2)),
716
+ speedKBps: parseFloat(t.speedKBps.toFixed(2)),
717
+ etaSeconds: Math.ceil(t.etaSeconds),
718
+ protocol: t.protocol,
719
+ status: t.status
720
+ })),
721
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
722
+ };
723
+ fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2), "utf-8");
724
+ }
725
+ function broadcastSSE(event, data) {
726
+ const payload = `event: ${event}
727
+ data: ${JSON.stringify(data)}
728
+
729
+ `;
730
+ sseClients.forEach((res) => res.write(payload));
731
+ }
732
+ function connectWebSocket(wsTargetUrl) {
733
+ if (wsConn) {
734
+ try {
735
+ wsConn.close();
736
+ } catch {
737
+ }
738
+ }
739
+ console.log(`[DAEMON] Connecting to Signaler WS: ${wsTargetUrl}`);
740
+ wsConn = new WebSocket(wsTargetUrl);
741
+ wsConn.on("open", () => {
742
+ console.log("[DAEMON] WebSocket connection open");
743
+ session.isConnected = true;
744
+ writeStateFile();
745
+ broadcastSSE("connection_state", {
746
+ sessionId: session.id,
747
+ isConnected: true,
748
+ signaling: "connected"
749
+ });
750
+ setTimeout(() => {
751
+ if (session.role === "host") {
752
+ wsConn?.send(JSON.stringify({
753
+ type: "init_host",
754
+ payload: { userId: session.userId, sessionId: session.id }
755
+ }));
756
+ } else if (session.role === "guest") {
757
+ if (session.userId) {
758
+ wsConn?.send(JSON.stringify({
759
+ type: "init_joiner",
760
+ payload: { userId: session.userId, sessionId: session.id }
761
+ }));
762
+ } else {
763
+ const username = process.env.SRIFT_USERNAME || "CLI-Agent";
764
+ wsConn?.send(JSON.stringify({
765
+ type: "request_join",
766
+ payload: { sessionId: session.id, tempUserId: session.tempUserId, username }
767
+ }));
768
+ }
769
+ }
770
+ }, 100);
771
+ lastHeartbeatAckAt = Date.now();
772
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
773
+ heartbeatInterval = setInterval(() => {
774
+ if (wsConn?.readyState === WebSocket.OPEN) {
775
+ wsConn.send(JSON.stringify({ type: "heartbeat", payload: { ts: Date.now() } }));
776
+ }
777
+ }, 3e4);
778
+ });
779
+ wsConn.on("message", async (dataStr) => {
780
+ try {
781
+ const msg = JSON.parse(dataStr);
782
+ const { type, payload } = msg;
783
+ switch (type) {
784
+ case "init_host_ack":
785
+ if (payload.status === "ok") {
786
+ csrfToken = payload.csrfToken;
787
+ console.log("[DAEMON] Host authentication OK, CSRF Token obtained");
788
+ reregisterPubsharesAfterReconnect().catch((e) => console.warn("[DAEMON] pubshare replay failed:", e?.message));
789
+ }
790
+ break;
791
+ case "request_join_ack":
792
+ console.log("[DAEMON] Join request received by server. Waiting for host approval...");
793
+ break;
794
+ case "join_approved":
795
+ console.log("[DAEMON] Host approved join request!");
796
+ session.userId = payload.userId;
797
+ wsConn?.send(JSON.stringify({
798
+ type: "init_joiner",
799
+ payload: { userId: session.userId, sessionId: session.id }
800
+ }));
801
+ writeStateFile();
802
+ break;
803
+ case "init_joiner_ack":
804
+ if (payload.status === "ok") {
805
+ csrfToken = payload.csrfToken;
806
+ console.log("[DAEMON] Approved joiner authentication OK");
807
+ }
808
+ break;
809
+ case "heartbeat_ack":
810
+ lastHeartbeatAckAt = Date.now();
811
+ break;
812
+ case "session_deleted":
813
+ console.log(`[DAEMON] Session deleted by host: ${payload.reason || ""}`);
814
+ sessionTerminated = true;
815
+ terminationReason = payload.message || "Session was deleted by the host";
816
+ session = { id: null, name: null, role: null, isConnected: false, userId: null };
817
+ activeTransfers = [];
818
+ pendingJoins = [];
819
+ encryptionKey = null;
820
+ writeStateFile();
821
+ broadcastSSE("session_terminated", { reason: terminationReason });
822
+ try {
823
+ if (wsConn) wsConn.close();
824
+ } catch {
825
+ }
826
+ break;
827
+ case "error":
828
+ console.error(`[DAEMON] Server error: ${payload.msg} (severity=${payload.severity || "recoverable"})`);
829
+ if (payload.severity === "critical") {
830
+ sessionTerminated = true;
831
+ terminationReason = payload.msg || "Connection terminated by host";
832
+ session.isConnected = false;
833
+ session.userId = null;
834
+ pendingJoins = [];
835
+ writeStateFile();
836
+ broadcastSSE("session_terminated", { reason: terminationReason });
837
+ }
838
+ break;
839
+ case "join_request":
840
+ console.log(`[DAEMON] Peer join request: tempUserId=${payload.tempUserId}, username=${payload.username}`);
841
+ pendingJoins.push({ tempUserId: payload.tempUserId, username: payload.username });
842
+ broadcastSSE("join_request", payload);
843
+ break;
844
+ case "chat":
845
+ try {
846
+ const decContent = await decrypt(payload.content);
847
+ const chatMsg = {
848
+ messageId: payload.messageId || uuidv4(),
849
+ sender: payload.username || "Peer",
850
+ content: decContent,
851
+ timestamp: payload.timestamp || (/* @__PURE__ */ new Date()).toISOString()
852
+ };
853
+ chatHistory.push(chatMsg);
854
+ broadcastSSE("chat_received", chatMsg);
855
+ console.log(`[DAEMON] Chat from ${chatMsg.sender}: ${chatMsg.content}`);
856
+ } catch (decErr) {
857
+ console.error("[DAEMON] Decryption failed for chat message:", decErr);
858
+ }
859
+ break;
860
+ case "file_offer":
861
+ console.log(`[DAEMON] File offer received: ${payload.filename} (${payload.size} bytes)`);
862
+ const newTx = {
863
+ fileId: payload.fileId,
864
+ name: payload.filename,
865
+ size: payload.size,
866
+ progress: 0,
867
+ speedKBps: 0,
868
+ etaSeconds: 0,
869
+ protocol: payload.transferType === "webtorrent" ? "webtorrent" : "websocket",
870
+ status: "pending",
871
+ direction: "download",
872
+ bytesTransferred: 0,
873
+ chunksCount: 0,
874
+ totalChunks: 0,
875
+ startTime: Date.now()
876
+ };
877
+ activeTransfers.push(newTx);
878
+ writeStateFile();
879
+ broadcastSSE("file_offer", {
880
+ fileId: payload.fileId,
881
+ filename: payload.filename,
882
+ size: payload.size,
883
+ from: payload.from,
884
+ protocol: newTx.protocol
885
+ });
886
+ break;
887
+ case "file_accept":
888
+ console.log(`[DAEMON] File offer accepted by peer! ID: ${payload.fileId}`);
889
+ startUpload(payload.fileId, payload.userId);
890
+ break;
891
+ case "webtorrent_info_hash":
892
+ handleWebTorrentInfoHash(payload);
893
+ break;
894
+ case "file_chunk":
895
+ handleIncomingChunk(payload);
896
+ break;
897
+ case "file_chunk_ack":
898
+ handleChunkAck(payload);
899
+ break;
900
+ case "file_complete":
901
+ handleFileComplete(payload);
902
+ break;
903
+ case "pubshare_register_ack": {
904
+ const entry = pubshares.get(payload.fileId);
905
+ if (entry) {
906
+ entry.token = payload.token;
907
+ entry.downloadUrl = payload.downloadUrl;
908
+ pubsharesByToken.set(payload.token, entry);
909
+ const resolver = pubshareRegResolvers.get(payload.fileId);
910
+ if (resolver) {
911
+ pubshareRegResolvers.delete(payload.fileId);
912
+ resolver(entry);
913
+ }
914
+ console.log(`[DAEMON] pubshare registered: ${entry.filename} -> ${entry.downloadUrl}`);
915
+ }
916
+ break;
917
+ }
918
+ case "pubshare_pull": {
919
+ handlePubsharePull(payload).catch((err) => {
920
+ console.error("[DAEMON] pubshare_pull failed:", err);
921
+ try {
922
+ wsConn?.send(JSON.stringify({
923
+ type: "pubshare_end",
924
+ payload: { requestId: payload.requestId, ok: false, error: String(err?.message || err) }
925
+ }));
926
+ } catch {
927
+ }
928
+ });
929
+ break;
930
+ }
931
+ case "pubshare_cancel": {
932
+ const a = activePulls.get(payload.requestId);
933
+ if (a) a.cancelled = true;
934
+ break;
935
+ }
936
+ }
937
+ } catch (err) {
938
+ console.error("[DAEMON] Error processing WS message:", err);
939
+ }
940
+ });
941
+ wsConn.on("close", () => {
942
+ console.log("[DAEMON] WebSocket connection closed");
943
+ session.isConnected = false;
944
+ writeStateFile();
945
+ broadcastSSE("connection_state", {
946
+ sessionId: session.id,
947
+ isConnected: false,
948
+ signaling: "disconnected"
949
+ });
950
+ if (sessionTerminated) {
951
+ console.log(`[DAEMON] Not reconnecting \u2014 session terminated: ${terminationReason}`);
952
+ return;
953
+ }
954
+ setTimeout(() => {
955
+ if (session.id && wsTargetUrl && !sessionTerminated) {
956
+ connectWebSocket(wsTargetUrl);
957
+ }
958
+ }, 3e3);
959
+ });
960
+ wsConn.on("error", (err) => {
961
+ console.error("[DAEMON] WebSocket error:", err);
962
+ });
963
+ }
964
+ function startUpload(fileId, targetUserId) {
965
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
966
+ if (!tx) return;
967
+ if (tx.protocol === "webtorrent") {
968
+ startWebTorrentUpload(fileId, targetUserId);
969
+ } else {
970
+ startWebSocketUpload(fileId, targetUserId);
971
+ }
972
+ }
973
+ async function startWebTorrentUpload(fileId, targetUserId) {
974
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
975
+ if (!tx || !tx.filePath) return;
976
+ console.log(`[DAEMON] Starting WebTorrent seeding for ${tx.name}...`);
977
+ tx.status = "uploading";
978
+ tx.startTime = Date.now();
979
+ writeStateFile();
980
+ let client;
981
+ try {
982
+ client = await getWtClient();
983
+ } catch (err) {
984
+ console.error(`[DAEMON] WebTorrent unavailable, falling back to WebSocket for ${tx.name}: ${err?.message}`);
985
+ tx.protocol = "websocket";
986
+ writeStateFile();
987
+ return;
988
+ }
989
+ const trackers = getTrackers(session.id || "");
990
+ client.seed(tx.filePath, { announce: trackers }, (torrent) => {
991
+ console.log(`[DAEMON] WebTorrent seeding active. Info Hash: ${torrent.infoHash}`);
992
+ activeTorrents.set(fileId, torrent);
993
+ wsConn?.send(JSON.stringify({
994
+ type: "webtorrent_info_hash",
995
+ payload: {
996
+ fileId,
997
+ infoHash: torrent.infoHash,
998
+ fileName: tx.name,
999
+ fileSize: tx.size,
1000
+ targetUserId
1001
+ }
1002
+ }));
1003
+ torrent.on("upload", () => {
1004
+ tx.bytesTransferred = torrent.uploaded;
1005
+ tx.progress = Math.min(99.9, torrent.uploaded / tx.size * 100);
1006
+ tx.speedKBps = torrent.uploadSpeed / 1024;
1007
+ writeStateFile();
1008
+ broadcastSSE("transfer_progress", {
1009
+ fileId,
1010
+ fileName: tx.name,
1011
+ size: tx.size,
1012
+ bytesTransferred: torrent.uploaded,
1013
+ progress: tx.progress,
1014
+ speedBytesPerSecond: torrent.uploadSpeed,
1015
+ timeRemainingSeconds: -1,
1016
+ status: "uploading"
1017
+ });
1018
+ });
1019
+ });
1020
+ }
1021
+ async function handleWebTorrentInfoHash(payload) {
1022
+ const { fileId, infoHash, fileName, fileSize, senderId } = payload;
1023
+ console.log(`[DAEMON] Received WebTorrent info hash: ${infoHash} for file: ${fileName}`);
1024
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1025
+ if (!tx) return;
1026
+ tx.protocol = "webtorrent";
1027
+ tx.status = "downloading";
1028
+ tx.peerId = senderId;
1029
+ tx.startTime = Date.now();
1030
+ writeStateFile();
1031
+ let client;
1032
+ try {
1033
+ client = await getWtClient();
1034
+ } catch (err) {
1035
+ console.error(`[DAEMON] WebTorrent unavailable for download of ${fileName}: ${err?.message}. Ask the sender to retry with --protocol websocket.`);
1036
+ tx.status = "error";
1037
+ writeStateFile();
1038
+ broadcastSSE("transfer_progress", {
1039
+ fileId,
1040
+ fileName: tx.name,
1041
+ size: tx.size,
1042
+ bytesTransferred: 0,
1043
+ progress: 0,
1044
+ speedBytesPerSecond: 0,
1045
+ timeRemainingSeconds: -1,
1046
+ status: "error"
1047
+ });
1048
+ return;
1049
+ }
1050
+ const trackers = getTrackers(session.id || "");
1051
+ const downloadDir = tx.saveDir || process.cwd();
1052
+ client.add(infoHash, { announce: trackers, path: downloadDir }, (torrent) => {
1053
+ console.log(`[DAEMON] WebTorrent download started: ${torrent.name}`);
1054
+ activeTorrents.set(fileId, torrent);
1055
+ torrent.on("download", () => {
1056
+ tx.bytesTransferred = torrent.downloaded;
1057
+ tx.progress = torrent.progress * 100;
1058
+ tx.speedKBps = torrent.downloadSpeed / 1024;
1059
+ tx.etaSeconds = torrent.timeRemaining / 1e3;
1060
+ writeStateFile();
1061
+ broadcastSSE("transfer_progress", {
1062
+ fileId,
1063
+ fileName: tx.name,
1064
+ size: tx.size,
1065
+ bytesTransferred: torrent.downloaded,
1066
+ progress: tx.progress,
1067
+ speedBytesPerSecond: torrent.downloadSpeed,
1068
+ timeRemainingSeconds: tx.etaSeconds,
1069
+ status: "downloading"
1070
+ });
1071
+ });
1072
+ torrent.on("done", () => {
1073
+ console.log(`[DAEMON] WebTorrent download finished: ${tx.name}`);
1074
+ tx.status = "completed";
1075
+ tx.progress = 100;
1076
+ tx.speedKBps = 0;
1077
+ tx.etaSeconds = 0;
1078
+ writeStateFile();
1079
+ broadcastSSE("transfer_progress", {
1080
+ fileId,
1081
+ fileName: tx.name,
1082
+ size: tx.size,
1083
+ bytesTransferred: tx.size,
1084
+ progress: 100,
1085
+ speedBytesPerSecond: 0,
1086
+ timeRemainingSeconds: 0,
1087
+ status: "completed"
1088
+ });
1089
+ torrent.destroy();
1090
+ activeTorrents.delete(fileId);
1091
+ });
1092
+ });
1093
+ }
1094
+ var activeUploads = /* @__PURE__ */ new Map();
1095
+ async function startWebSocketUpload(fileId, targetUserId) {
1096
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1097
+ if (!tx || !tx.filePath) return;
1098
+ const chunkSize = 64 * 1024;
1099
+ const stat = fs.statSync(tx.filePath);
1100
+ const totalChunks = Math.max(1, Math.ceil(stat.size / chunkSize));
1101
+ tx.status = "uploading";
1102
+ tx.startTime = Date.now();
1103
+ tx.totalChunks = totalChunks;
1104
+ writeStateFile();
1105
+ activeUploads.set(fileId, {
1106
+ filePath: tx.filePath,
1107
+ totalChunks,
1108
+ chunkSize,
1109
+ targetUserId,
1110
+ currentChunk: 0,
1111
+ bytesSent: 0,
1112
+ startTime: Date.now()
1113
+ });
1114
+ sendNextChunk(fileId);
1115
+ }
1116
+ function sendNextChunk(fileId) {
1117
+ const upload = activeUploads.get(fileId);
1118
+ if (!upload) return;
1119
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1120
+ if (!tx) return;
1121
+ if (upload.currentChunk >= upload.totalChunks) {
1122
+ wsConn?.send(JSON.stringify({
1123
+ type: "file_complete",
1124
+ payload: { fileId, targetUserId: upload.targetUserId }
1125
+ }));
1126
+ tx.status = "completed";
1127
+ tx.progress = 100;
1128
+ tx.speedKBps = 0;
1129
+ tx.etaSeconds = 0;
1130
+ writeStateFile();
1131
+ activeUploads.delete(fileId);
1132
+ console.log(`[DAEMON] File upload complete: ${tx.name}`);
1133
+ return;
1134
+ }
1135
+ const start = upload.currentChunk * upload.chunkSize;
1136
+ const stat = fs.statSync(upload.filePath);
1137
+ const end = Math.min(start + upload.chunkSize, stat.size);
1138
+ const buffer = Buffer.alloc(end - start);
1139
+ const fd = fs.openSync(upload.filePath, "r");
1140
+ fs.readSync(fd, buffer, 0, end - start, start);
1141
+ fs.closeSync(fd);
1142
+ const chunkBase64 = buffer.toString("base64");
1143
+ wsConn?.send(JSON.stringify({
1144
+ type: "file_chunk",
1145
+ payload: {
1146
+ fileId,
1147
+ chunk: chunkBase64,
1148
+ chunkIndex: upload.currentChunk,
1149
+ totalChunks: upload.totalChunks,
1150
+ targetUserId: upload.targetUserId
1151
+ }
1152
+ }));
1153
+ tx.bytesTransferred = end;
1154
+ tx.progress = end / stat.size * 100;
1155
+ const elapsed = (Date.now() - upload.startTime) / 1e3;
1156
+ if (elapsed > 0) {
1157
+ tx.speedKBps = end / 1024 / elapsed;
1158
+ tx.etaSeconds = (stat.size - end) / (tx.speedKBps * 1024);
1159
+ }
1160
+ writeStateFile();
1161
+ broadcastSSE("transfer_progress", {
1162
+ fileId,
1163
+ fileName: tx.name,
1164
+ size: tx.size,
1165
+ bytesTransferred: end,
1166
+ progress: tx.progress,
1167
+ speedBytesPerSecond: tx.speedKBps * 1024,
1168
+ timeRemainingSeconds: tx.etaSeconds,
1169
+ status: "uploading"
1170
+ });
1171
+ }
1172
+ function handleChunkAck(payload) {
1173
+ const { fileId, chunkIndex } = payload;
1174
+ const upload = activeUploads.get(fileId);
1175
+ if (!upload) return;
1176
+ if (upload.currentChunk === chunkIndex) {
1177
+ upload.currentChunk++;
1178
+ sendNextChunk(fileId);
1179
+ }
1180
+ }
1181
+ function handleIncomingChunk(payload) {
1182
+ const { fileId, chunk, chunkIndex, totalChunks, from } = payload;
1183
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1184
+ if (!tx) return;
1185
+ tx.status = "downloading";
1186
+ tx.totalChunks = totalChunks;
1187
+ tx.peerId = from;
1188
+ const chunkBuffer = Buffer.from(chunk, "base64");
1189
+ tx.bytesTransferred += chunkBuffer.length;
1190
+ tx.chunksCount++;
1191
+ const chunkPath = path.join(TEMP_DIR, `${fileId}_chunk_${chunkIndex}`);
1192
+ fs.writeFileSync(chunkPath, chunkBuffer);
1193
+ tx.progress = tx.bytesTransferred / tx.size * 100;
1194
+ const elapsed = (Date.now() - tx.startTime) / 1e3;
1195
+ if (elapsed > 0) {
1196
+ tx.speedKBps = tx.bytesTransferred / 1024 / elapsed;
1197
+ tx.etaSeconds = (tx.size - tx.bytesTransferred) / (tx.speedKBps * 1024);
1198
+ }
1199
+ writeStateFile();
1200
+ broadcastSSE("transfer_progress", {
1201
+ fileId,
1202
+ fileName: tx.name,
1203
+ size: tx.size,
1204
+ bytesTransferred: tx.bytesTransferred,
1205
+ progress: tx.progress,
1206
+ speedBytesPerSecond: tx.speedKBps * 1024,
1207
+ timeRemainingSeconds: tx.etaSeconds,
1208
+ status: "downloading"
1209
+ });
1210
+ wsConn?.send(JSON.stringify({
1211
+ type: "file_chunk_ack",
1212
+ payload: { fileId, chunkIndex, status: "received" }
1213
+ }));
1214
+ }
1215
+ function handleFileComplete(payload) {
1216
+ const { fileId } = payload;
1217
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1218
+ if (!tx) return;
1219
+ const destDir = tx.saveDir || process.cwd();
1220
+ const destPath = path.join(destDir, tx.name);
1221
+ try {
1222
+ if (!fs.existsSync(destDir)) {
1223
+ fs.mkdirSync(destDir, { recursive: true });
1224
+ }
1225
+ } catch (err) {
1226
+ console.error(`[DAEMON] Failed to create destination directory ${destDir}:`, err);
1227
+ tx.status = "error";
1228
+ writeStateFile();
1229
+ return;
1230
+ }
1231
+ const writeStream = fs.createWriteStream(destPath);
1232
+ writeStream.on("error", (err) => {
1233
+ console.error(`[DAEMON] Write stream error for ${destPath}:`, err);
1234
+ tx.status = "error";
1235
+ writeStateFile();
1236
+ broadcastSSE("transfer_progress", {
1237
+ fileId,
1238
+ fileName: tx.name,
1239
+ size: tx.size,
1240
+ bytesTransferred: tx.bytesTransferred,
1241
+ progress: tx.progress,
1242
+ speedBytesPerSecond: 0,
1243
+ timeRemainingSeconds: 0,
1244
+ status: "error"
1245
+ });
1246
+ });
1247
+ writeStream.on("finish", () => {
1248
+ console.log(`[DAEMON] File successfully downloaded and assembled: ${destPath}`);
1249
+ tx.status = "completed";
1250
+ tx.progress = 100;
1251
+ tx.speedKBps = 0;
1252
+ tx.etaSeconds = 0;
1253
+ writeStateFile();
1254
+ broadcastSSE("transfer_progress", {
1255
+ fileId,
1256
+ fileName: tx.name,
1257
+ size: tx.size,
1258
+ bytesTransferred: tx.size,
1259
+ progress: 100,
1260
+ speedBytesPerSecond: 0,
1261
+ timeRemainingSeconds: 0,
1262
+ status: "completed"
1263
+ });
1264
+ });
1265
+ for (let i = 0; i < tx.totalChunks; i++) {
1266
+ const chunkPath = path.join(TEMP_DIR, `${fileId}_chunk_${i}`);
1267
+ if (fs.existsSync(chunkPath)) {
1268
+ try {
1269
+ const chunkBuf = fs.readFileSync(chunkPath);
1270
+ writeStream.write(chunkBuf);
1271
+ fs.unlinkSync(chunkPath);
1272
+ } catch (err) {
1273
+ console.error(`[DAEMON] Error reading/writing chunk ${i}:`, err);
1274
+ writeStream.emit("error", err);
1275
+ return;
1276
+ }
1277
+ }
1278
+ }
1279
+ writeStream.end();
1280
+ }
1281
+ var app = express();
1282
+ app.use(cors());
1283
+ app.use(express.json());
1284
+ var DAEMON_START_TIME = Date.now();
1285
+ var PACKAGE_VERSION = "2.1.8";
1286
+ app.get("/health", (req, res) => {
1287
+ res.json({
1288
+ ok: true,
1289
+ version: PACKAGE_VERSION,
1290
+ uptime_ms: Date.now() - DAEMON_START_TIME,
1291
+ mcp: true,
1292
+ webrtc: false,
1293
+ // daemon uses WebSocket relay + WebTorrent, not raw WebRTC
1294
+ webtorrent: _webTorrentLoadError ? false : true,
1295
+ webtorrent_error: _webTorrentLoadError || void 0
1296
+ });
1297
+ });
1298
+ app.get("/status", (req, res) => {
1299
+ res.json({
1300
+ session: {
1301
+ id: session.id,
1302
+ name: session.name,
1303
+ role: session.role,
1304
+ isConnected: session.isConnected,
1305
+ userId: session.userId
1306
+ },
1307
+ activeTransfers,
1308
+ pendingJoins,
1309
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
1310
+ });
1311
+ });
1312
+ app.post("/session/start", async (req, res) => {
1313
+ try {
1314
+ const { sessionName, username, roomSecret } = req.body;
1315
+ const sName = sessionName || "cli-session";
1316
+ const uName = username || "CLI-Host";
1317
+ sessionTerminated = false;
1318
+ terminationReason = null;
1319
+ console.log(`[DAEMON] Starting session: "${sName}" by "${uName}"`);
1320
+ const response = await fetch(`${activeSignalerUrl}/create-session`, {
1321
+ method: "POST",
1322
+ headers: { "Content-Type": "application/json" },
1323
+ body: JSON.stringify({ username: uName, name: sName })
1324
+ });
1325
+ if (!response.ok) {
1326
+ throw new Error(`Signaler returned status ${response.status}`);
1327
+ }
1328
+ const data = await response.json();
1329
+ console.log("[DAEMON] Session created successfully on server", data);
1330
+ session = {
1331
+ id: data.sessionId,
1332
+ name: data.name,
1333
+ role: "host",
1334
+ isConnected: false,
1335
+ userId: data.userId,
1336
+ wsToken: data.wsToken,
1337
+ roomSecret: roomSecret || null
1338
+ };
1339
+ encryptionKey = await deriveKey(data.sessionId, roomSecret);
1340
+ console.log("[DAEMON] E2EE Cryptographic key derived");
1341
+ wsUrl = data.wsUrl || `${activeSignalerUrl.replace(/^http/, "ws")}/ws`;
1342
+ if (wsUrl) connectWebSocket(wsUrl);
1343
+ writeStateFile();
1344
+ res.json({ success: true, sessionId: data.sessionId });
1345
+ } catch (err) {
1346
+ console.error("[DAEMON] Failed to start session:", err);
1347
+ res.status(500).json({ success: false, error: err.message });
1348
+ }
1349
+ });
1350
+ app.post("/session/join", async (req, res) => {
1351
+ try {
1352
+ const { sessionId, username, roomSecret } = req.body;
1353
+ if (!sessionId) {
1354
+ return res.status(400).json({ success: false, error: "sessionId required" });
1355
+ }
1356
+ sessionTerminated = false;
1357
+ terminationReason = null;
1358
+ const uName = username || "CLI-Guest";
1359
+ console.log(`[DAEMON] Joining session: "${sessionId}" as "${uName}"`);
1360
+ const response = await fetch(`${activeSignalerUrl}/join-session`, {
1361
+ method: "POST",
1362
+ headers: { "Content-Type": "application/json" },
1363
+ body: JSON.stringify({ sessionId, username: uName })
1364
+ });
1365
+ if (!response.ok) {
1366
+ let errBody = {};
1367
+ try {
1368
+ errBody = await response.json();
1369
+ } catch {
1370
+ }
1371
+ const errMsg = errBody.error || `Signaler returned status ${response.status}`;
1372
+ const statusCode = response.status >= 400 && response.status < 500 ? response.status : 502;
1373
+ return res.status(statusCode).json({ success: false, error: errMsg });
1374
+ }
1375
+ const data = await response.json();
1376
+ console.log("[DAEMON] Join initialized on server", data);
1377
+ session = {
1378
+ id: data.sessionId,
1379
+ name: data.name,
1380
+ role: "guest",
1381
+ isConnected: false,
1382
+ userId: null,
1383
+ tempUserId: data.tempUserId,
1384
+ wsToken: data.wsToken,
1385
+ roomSecret: roomSecret || null
1386
+ };
1387
+ encryptionKey = await deriveKey(data.sessionId, roomSecret);
1388
+ console.log("[DAEMON] E2EE Cryptographic key derived");
1389
+ wsUrl = data.wsUrl || `${activeSignalerUrl.replace(/^http/, "ws")}/ws`;
1390
+ if (wsUrl) connectWebSocket(wsUrl);
1391
+ writeStateFile();
1392
+ res.json({ success: true, sessionId: data.sessionId });
1393
+ } catch (err) {
1394
+ console.error("[DAEMON] Failed to join session:", err);
1395
+ res.status(500).json({ success: false, error: err.message });
1396
+ }
1397
+ });
1398
+ app.post("/session/approve", (req, res) => {
1399
+ const { tempUserId } = req.body;
1400
+ if (!tempUserId) {
1401
+ return res.status(400).json({ success: false, error: "tempUserId required" });
1402
+ }
1403
+ if (session.role !== "host") {
1404
+ return res.status(403).json({ success: false, error: "Only host can approve joins" });
1405
+ }
1406
+ const pending = pendingJoins.find((j) => j.tempUserId === tempUserId);
1407
+ if (!pending) {
1408
+ return res.status(404).json({ success: false, error: `No pending join request found for tempUserId: ${tempUserId}` });
1409
+ }
1410
+ console.log(`[DAEMON] Approving join request: ${tempUserId}`);
1411
+ wsConn?.send(JSON.stringify({
1412
+ type: "approve_join",
1413
+ payload: { sessionId: session.id, tempUserId }
1414
+ }));
1415
+ pendingJoins = pendingJoins.filter((j) => j.tempUserId !== tempUserId);
1416
+ res.json({ success: true });
1417
+ });
1418
+ app.post("/session/reject", (req, res) => {
1419
+ const { tempUserId, reason } = req.body;
1420
+ if (!tempUserId) {
1421
+ return res.status(400).json({ success: false, error: "tempUserId required" });
1422
+ }
1423
+ if (session.role !== "host") {
1424
+ return res.status(403).json({ success: false, error: "Only host can reject joins" });
1425
+ }
1426
+ console.log(`[DAEMON] Rejecting join request: ${tempUserId}`);
1427
+ wsConn?.send(JSON.stringify({
1428
+ type: "reject_join",
1429
+ payload: { tempUserId, reason: reason || "Kicked by host CLI" }
1430
+ }));
1431
+ pendingJoins = pendingJoins.filter((j) => j.tempUserId !== tempUserId);
1432
+ res.json({ success: true });
1433
+ });
1434
+ app.post("/session/kick", (req, res) => {
1435
+ const { userId } = req.body;
1436
+ if (!userId) {
1437
+ return res.status(400).json({ success: false, error: "userId required" });
1438
+ }
1439
+ if (session.role !== "host") {
1440
+ return res.status(403).json({ success: false, error: "Only host can kick users" });
1441
+ }
1442
+ console.log(`[DAEMON] Kicking user: ${userId}`);
1443
+ wsConn?.send(JSON.stringify({
1444
+ type: "kick",
1445
+ payload: { targetId: userId }
1446
+ }));
1447
+ res.json({ success: true });
1448
+ });
1449
+ app.post("/session/close", (req, res) => {
1450
+ console.log("[DAEMON] Closing active session \u2014 full teardown");
1451
+ sessionTerminated = true;
1452
+ try {
1453
+ console.log(`[DAEMON] close: role=${session.role} id=${session.id} wsState=${wsConn ? wsConn.readyState : "no-ws"}`);
1454
+ if (wsConn && wsConn.readyState === 1 && session.id) {
1455
+ if (session.role === "host") {
1456
+ wsConn.send(JSON.stringify({ type: "delete_session", payload: { sessionId: session.id } }));
1457
+ console.log("[DAEMON] close: sent delete_session");
1458
+ } else {
1459
+ wsConn.send(JSON.stringify({ type: "leave_session", payload: { sessionId: session.id, userId: session.userId } }));
1460
+ console.log("[DAEMON] close: sent leave_session");
1461
+ }
1462
+ }
1463
+ } catch (e) {
1464
+ console.error("[DAEMON] close: send failed", e?.message);
1465
+ }
1466
+ if (wsConn) {
1467
+ const _ws = wsConn;
1468
+ setTimeout(() => {
1469
+ try {
1470
+ _ws.close();
1471
+ } catch {
1472
+ }
1473
+ }, 700);
1474
+ wsConn = null;
1475
+ }
1476
+ wsUrl = null;
1477
+ if (heartbeatInterval) {
1478
+ clearInterval(heartbeatInterval);
1479
+ heartbeatInterval = null;
1480
+ }
1481
+ if (wtClient) {
1482
+ try {
1483
+ wtClient.destroy();
1484
+ } catch {
1485
+ }
1486
+ wtClient = null;
1487
+ }
1488
+ activeTorrents.clear();
1489
+ session = { id: null, name: null, role: null, isConnected: false, userId: null };
1490
+ activeTransfers = [];
1491
+ chatHistory = [];
1492
+ pendingJoins = [];
1493
+ csrfToken = null;
1494
+ encryptionKey = null;
1495
+ try {
1496
+ if (fs.existsSync(TEMP_DIR)) {
1497
+ for (const f of fs.readdirSync(TEMP_DIR)) {
1498
+ try {
1499
+ fs.unlinkSync(path.join(TEMP_DIR, f));
1500
+ } catch {
1501
+ }
1502
+ }
1503
+ }
1504
+ } catch {
1505
+ }
1506
+ writeStateFile();
1507
+ res.json({ success: true });
1508
+ });
1509
+ app.post("/send", async (req, res) => {
1510
+ try {
1511
+ const { filePath, protocol } = req.body;
1512
+ if (!filePath) {
1513
+ return res.status(400).json({ success: false, error: "filePath is required" });
1514
+ }
1515
+ const absPath = path.resolve(filePath);
1516
+ if (!fs.existsSync(absPath)) {
1517
+ return res.status(404).json({ success: false, error: `File not found: ${absPath}` });
1518
+ }
1519
+ const stat = fs.statSync(absPath);
1520
+ const filename = path.basename(absPath);
1521
+ const fileId = `cli_file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1522
+ const targetProtocol = protocol === "webtorrent" || stat.size > 10 * 1024 * 1024 && protocol !== "websocket" ? "webtorrent" : "websocket";
1523
+ const newTx = {
1524
+ fileId,
1525
+ name: filename,
1526
+ size: stat.size,
1527
+ progress: 0,
1528
+ speedKBps: 0,
1529
+ etaSeconds: 0,
1530
+ protocol: targetProtocol,
1531
+ status: "pending",
1532
+ direction: "upload",
1533
+ filePath: absPath,
1534
+ bytesTransferred: 0,
1535
+ chunksCount: 0,
1536
+ totalChunks: 0,
1537
+ startTime: Date.now()
1538
+ };
1539
+ activeTransfers.push(newTx);
1540
+ writeStateFile();
1541
+ console.log(`[DAEMON] Offering file via ${targetProtocol}: ${filename} (${stat.size} bytes)`);
1542
+ wsConn?.send(JSON.stringify({
1543
+ type: "file_offer",
1544
+ payload: {
1545
+ fileId,
1546
+ filename,
1547
+ size: stat.size,
1548
+ mime: "application/octet-stream",
1549
+ transferType: targetProtocol,
1550
+ csrfToken
1551
+ }
1552
+ }));
1553
+ res.json({ success: true, fileId, protocol: targetProtocol });
1554
+ } catch (err) {
1555
+ console.error("[DAEMON] Send offer failed:", err);
1556
+ res.status(500).json({ success: false, error: err.message });
1557
+ }
1558
+ });
1559
+ app.post("/receive", (req, res) => {
1560
+ const { fileId, saveDir } = req.body;
1561
+ if (!fileId) {
1562
+ return res.status(400).json({ success: false, error: "fileId is required" });
1563
+ }
1564
+ const tx = activeTransfers.find((t) => t.fileId === fileId);
1565
+ if (!tx) {
1566
+ return res.status(404).json({ success: false, error: "No offer found for this fileId" });
1567
+ }
1568
+ tx.saveDir = saveDir ? path.resolve(saveDir) : process.cwd();
1569
+ tx.status = "downloading";
1570
+ tx.startTime = Date.now();
1571
+ console.log(`[DAEMON] Accepting file offer ${fileId}, saving to ${tx.saveDir}`);
1572
+ wsConn?.send(JSON.stringify({
1573
+ type: "file_accept",
1574
+ payload: { fileId }
1575
+ }));
1576
+ res.json({ success: true });
1577
+ });
1578
+ app.post("/chat/send", async (req, res) => {
1579
+ try {
1580
+ const { message } = req.body;
1581
+ if (!message) {
1582
+ return res.status(400).json({ success: false, error: "message required" });
1583
+ }
1584
+ if (!session.id) {
1585
+ return res.status(409).json({ success: false, error: "No active session. Call /session/start first." });
1586
+ }
1587
+ if (!encryptionKey) {
1588
+ return res.status(409).json({ success: false, error: "Encryption key not ready. Session still initialising." });
1589
+ }
1590
+ if (!wsConn || wsConn.readyState !== 1) {
1591
+ return res.status(503).json({
1592
+ success: false,
1593
+ error: "Signaling connection not ready (WebSocket is connecting). Retry in 1\u20132 seconds.",
1594
+ retryAfterMs: 1500
1595
+ });
1596
+ }
1597
+ const encContent = await encrypt(message);
1598
+ wsConn.send(JSON.stringify({
1599
+ type: "chat",
1600
+ payload: { content: encContent, encrypted: true }
1601
+ }));
1602
+ const chatMsg = {
1603
+ messageId: uuidv4(),
1604
+ sender: "Me",
1605
+ content: message,
1606
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1607
+ };
1608
+ chatHistory.push(chatMsg);
1609
+ res.json({ success: true });
1610
+ } catch (err) {
1611
+ res.status(500).json({ success: false, error: err.message });
1612
+ }
1613
+ });
1614
+ app.get("/chat/history", (req, res) => {
1615
+ res.json(chatHistory);
1616
+ });
1617
+ app.get("/state", (req, res) => {
1618
+ const statePath = path.join(process.cwd(), ".srift-state.json");
1619
+ try {
1620
+ if (fs.existsSync(statePath)) {
1621
+ const raw = fs.readFileSync(statePath, "utf-8");
1622
+ res.type("application/json").send(raw);
1623
+ } else {
1624
+ res.json({ session: { id: null, isConnected: false }, activeTransfers: [], lastUpdated: null });
1625
+ }
1626
+ } catch (err) {
1627
+ res.status(500).json({ error: err.message });
1628
+ }
1629
+ });
1630
+ async function registerPubshare(fileId, absPath, filename, size, mime, opts = {}, timeoutMs = 5e3) {
1631
+ if (!wsConn || wsConn.readyState !== WebSocket.OPEN) {
1632
+ throw new Error("Signaling WebSocket not connected");
1633
+ }
1634
+ const maxDownloads = Math.max(0, Math.floor(opts.maxDownloads || 0));
1635
+ const expiresAt = opts.ttlMs && opts.ttlMs > 0 ? Date.now() + opts.ttlMs : null;
1636
+ const entry = {
1637
+ token: opts.preferToken || null,
1638
+ filePath: absPath,
1639
+ filename,
1640
+ size,
1641
+ mime,
1642
+ fileId,
1643
+ downloadUrl: null,
1644
+ maxDownloads,
1645
+ expiresAt,
1646
+ downloadCount: 0,
1647
+ createdAt: Date.now()
1648
+ };
1649
+ pubshares.set(fileId, entry);
1650
+ const ackP = new Promise((resolve, reject) => {
1651
+ pubshareRegResolvers.set(fileId, resolve);
1652
+ setTimeout(() => {
1653
+ if (pubshareRegResolvers.has(fileId)) {
1654
+ pubshareRegResolvers.delete(fileId);
1655
+ pubshares.delete(fileId);
1656
+ reject(new Error(
1657
+ "Signaler does not recognise pubshare_register \u2014 likely running an older server build. Until srift.app picks up the new release, the recipient must join the session in a browser."
1658
+ ));
1659
+ }
1660
+ }, timeoutMs);
1661
+ });
1662
+ wsConn.send(JSON.stringify({
1663
+ type: "pubshare_register",
1664
+ payload: {
1665
+ fileId,
1666
+ filename,
1667
+ size,
1668
+ mime,
1669
+ maxDownloads,
1670
+ expiresAt,
1671
+ preferToken: opts.preferToken || void 0
1672
+ }
1673
+ }));
1674
+ return ackP;
1675
+ }
1676
+ async function reregisterPubsharesAfterReconnect() {
1677
+ if (!pubshares.size) return;
1678
+ console.log(`[DAEMON] Re-registering ${pubshares.size} pubshare(s) after WS reconnect\u2026`);
1679
+ for (const entry of Array.from(pubshares.values())) {
1680
+ if (!entry.token) continue;
1681
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
1682
+ pubshares.delete(entry.fileId);
1683
+ pubsharesByToken.delete(entry.token);
1684
+ continue;
1685
+ }
1686
+ if (entry.maxDownloads && entry.downloadCount >= entry.maxDownloads) {
1687
+ pubshares.delete(entry.fileId);
1688
+ pubsharesByToken.delete(entry.token);
1689
+ continue;
1690
+ }
1691
+ try {
1692
+ const remainingTtl = entry.expiresAt ? Math.max(0, entry.expiresAt - Date.now()) : 0;
1693
+ const remainingMax = entry.maxDownloads ? Math.max(1, entry.maxDownloads - entry.downloadCount) : 0;
1694
+ await registerPubshare(
1695
+ entry.fileId,
1696
+ entry.filePath,
1697
+ entry.filename,
1698
+ entry.size,
1699
+ entry.mime,
1700
+ { maxDownloads: remainingMax, ttlMs: remainingTtl, preferToken: entry.token }
1701
+ );
1702
+ } catch (e) {
1703
+ console.warn(`[DAEMON] Failed to re-register pubshare ${entry.fileId}: ${e?.message}`);
1704
+ }
1705
+ }
1706
+ }
1707
+ async function handlePubsharePull(payload) {
1708
+ const { requestId, token, start, end, isFullDownload } = payload || {};
1709
+ if (!requestId || !token) return;
1710
+ const entry = pubsharesByToken.get(token);
1711
+ if (!entry) {
1712
+ wsConn?.send(JSON.stringify({
1713
+ type: "pubshare_end",
1714
+ payload: { requestId, ok: false, error: "unknown token" }
1715
+ }));
1716
+ return;
1717
+ }
1718
+ const cancelFlag = { cancelled: false };
1719
+ activePulls.set(requestId, cancelFlag);
1720
+ const CHUNK = 64 * 1024;
1721
+ let pos = start;
1722
+ let seq = 0;
1723
+ let completedOk = false;
1724
+ const fd = fs.openSync(entry.filePath, "r");
1725
+ try {
1726
+ while (pos <= end) {
1727
+ if (cancelFlag.cancelled) break;
1728
+ if (!wsConn || wsConn.readyState !== WebSocket.OPEN) {
1729
+ throw new Error("Signaling WebSocket disconnected mid-stream");
1730
+ }
1731
+ const len = Math.min(CHUNK, end - pos + 1);
1732
+ const buf = Buffer.allocUnsafe(len);
1733
+ fs.readSync(fd, buf, 0, len, pos);
1734
+ const dataB64 = buf.toString("base64");
1735
+ wsConn.send(JSON.stringify({
1736
+ type: "pubshare_chunk",
1737
+ payload: { requestId, seq, dataB64 }
1738
+ }));
1739
+ pos += len;
1740
+ seq++;
1741
+ if (wsConn.bufferedAmount > 8 * 1024 * 1024) {
1742
+ await new Promise((r) => setTimeout(r, 20));
1743
+ }
1744
+ }
1745
+ if (!cancelFlag.cancelled) {
1746
+ wsConn?.send(JSON.stringify({
1747
+ type: "pubshare_end",
1748
+ payload: { requestId, ok: true }
1749
+ }));
1750
+ completedOk = true;
1751
+ }
1752
+ } finally {
1753
+ try {
1754
+ fs.closeSync(fd);
1755
+ } catch {
1756
+ }
1757
+ activePulls.delete(requestId);
1758
+ }
1759
+ if (completedOk && isFullDownload) {
1760
+ entry.downloadCount++;
1761
+ broadcastSSE("pubshare_download", {
1762
+ token: entry.token,
1763
+ fileId: entry.fileId,
1764
+ filename: entry.filename,
1765
+ downloadCount: entry.downloadCount,
1766
+ maxDownloads: entry.maxDownloads
1767
+ });
1768
+ if (entry.maxDownloads && entry.downloadCount >= entry.maxDownloads) {
1769
+ console.log(`[DAEMON] pubshare exhausted (max ${entry.maxDownloads} reached): ${entry.filename}`);
1770
+ }
1771
+ }
1772
+ }
1773
+ app.post("/quick-share", async (req, res) => {
1774
+ try {
1775
+ const { filePath, sessionName, maxDownloads, ttlMs } = req.body;
1776
+ if (!filePath) return res.status(400).json({ success: false, error: "filePath required" });
1777
+ if (!session.id) {
1778
+ const sName = sessionName || "AI-QuickShare";
1779
+ const createRes = await fetch(`${activeSignalerUrl}/create-session`, {
1780
+ method: "POST",
1781
+ headers: { "Content-Type": "application/json" },
1782
+ body: JSON.stringify({ username: "AI-Agent", name: sName })
1783
+ });
1784
+ if (!createRes.ok) throw new Error(`Signaler returned ${createRes.status}`);
1785
+ const data = await createRes.json();
1786
+ session = {
1787
+ id: data.sessionId,
1788
+ name: data.name,
1789
+ role: "host",
1790
+ isConnected: false,
1791
+ userId: data.userId,
1792
+ wsToken: data.wsToken,
1793
+ roomSecret: null
1794
+ };
1795
+ encryptionKey = await deriveKey(data.sessionId);
1796
+ wsUrl = data.wsUrl || `${activeSignalerUrl.replace(/^http/, "ws")}/ws`;
1797
+ if (wsUrl) connectWebSocket(wsUrl);
1798
+ writeStateFile();
1799
+ await new Promise((r) => setTimeout(r, 800));
1800
+ }
1801
+ const absPath = path.resolve(filePath);
1802
+ if (!fs.existsSync(absPath)) return res.status(404).json({ success: false, error: `File not found: ${absPath}` });
1803
+ const stat = fs.statSync(absPath);
1804
+ const filename = path.basename(absPath);
1805
+ const fileId = `cli_file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1806
+ const targetProtocol = stat.size > 10 * 1024 * 1024 ? "webtorrent" : "websocket";
1807
+ activeTransfers.push({
1808
+ fileId,
1809
+ name: filename,
1810
+ size: stat.size,
1811
+ progress: 0,
1812
+ speedKBps: 0,
1813
+ etaSeconds: 0,
1814
+ protocol: targetProtocol,
1815
+ status: "pending",
1816
+ direction: "upload",
1817
+ filePath: absPath,
1818
+ bytesTransferred: 0,
1819
+ chunksCount: 0,
1820
+ totalChunks: 0,
1821
+ startTime: Date.now()
1822
+ });
1823
+ writeStateFile();
1824
+ wsConn?.send(JSON.stringify({
1825
+ type: "file_offer",
1826
+ payload: { fileId, filename, size: stat.size, mime: "application/octet-stream", transferType: targetProtocol, csrfToken }
1827
+ }));
1828
+ let downloadUrl = null;
1829
+ let expiresAt = null;
1830
+ let cappedMaxDownloads = 0;
1831
+ try {
1832
+ const entry = await registerPubshare(
1833
+ fileId,
1834
+ absPath,
1835
+ filename,
1836
+ stat.size,
1837
+ "application/octet-stream",
1838
+ {
1839
+ maxDownloads: typeof maxDownloads === "number" ? maxDownloads : 0,
1840
+ ttlMs: typeof ttlMs === "number" ? ttlMs : 0
1841
+ }
1842
+ );
1843
+ downloadUrl = entry.downloadUrl;
1844
+ expiresAt = entry.expiresAt;
1845
+ cappedMaxDownloads = entry.maxDownloads;
1846
+ } catch (e) {
1847
+ console.warn("[DAEMON] pubshare registration failed (link unavailable):", e?.message);
1848
+ }
1849
+ const publicBase = process.env.SRIFT_PUBLIC_BASE || "https://srift.app";
1850
+ res.json({
1851
+ success: true,
1852
+ sessionId: session.id,
1853
+ fileId,
1854
+ downloadUrl,
1855
+ // ← preferred (zero-install)
1856
+ shareUrl: downloadUrl || `${publicBase}/join-session?id=${session.id}`,
1857
+ // back-compat
1858
+ fileName: filename,
1859
+ fileSize: stat.size,
1860
+ maxDownloads: cappedMaxDownloads,
1861
+ // 0 = unlimited
1862
+ expiresAt
1863
+ // epoch ms or null
1864
+ });
1865
+ } catch (err) {
1866
+ console.error("[DAEMON] /quick-share failed:", err);
1867
+ res.status(500).json({ success: false, error: err.message });
1868
+ }
1869
+ });
1870
+ app.post("/pubshare", async (req, res) => {
1871
+ try {
1872
+ const { filePath, maxDownloads, ttlMs } = req.body;
1873
+ if (!filePath) return res.status(400).json({ success: false, error: "filePath required" });
1874
+ if (!session.id || !session.isConnected) {
1875
+ return res.status(409).json({ success: false, error: "No active session \u2014 run `srift session start` or `srift quick-share` first" });
1876
+ }
1877
+ const absPath = path.resolve(filePath);
1878
+ if (!fs.existsSync(absPath)) return res.status(404).json({ success: false, error: `File not found: ${absPath}` });
1879
+ const stat = fs.statSync(absPath);
1880
+ const filename = path.basename(absPath);
1881
+ const fileId = `cli_file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1882
+ const entry = await registerPubshare(
1883
+ fileId,
1884
+ absPath,
1885
+ filename,
1886
+ stat.size,
1887
+ "application/octet-stream",
1888
+ {
1889
+ maxDownloads: typeof maxDownloads === "number" ? maxDownloads : 0,
1890
+ ttlMs: typeof ttlMs === "number" ? ttlMs : 0
1891
+ }
1892
+ );
1893
+ res.json({
1894
+ success: true,
1895
+ sessionId: session.id,
1896
+ fileId,
1897
+ token: entry.token,
1898
+ downloadUrl: entry.downloadUrl,
1899
+ fileName: filename,
1900
+ fileSize: stat.size,
1901
+ maxDownloads: entry.maxDownloads,
1902
+ expiresAt: entry.expiresAt
1903
+ });
1904
+ } catch (err) {
1905
+ console.error("[DAEMON] /pubshare failed:", err);
1906
+ res.status(500).json({ success: false, error: err.message });
1907
+ }
1908
+ });
1909
+ app.get("/pubshare/list", (_req, res) => {
1910
+ const now = Date.now();
1911
+ const items = Array.from(pubshares.values()).filter((e) => !e.expiresAt || e.expiresAt > now).map((e) => ({
1912
+ token: e.token,
1913
+ downloadUrl: e.downloadUrl,
1914
+ fileId: e.fileId,
1915
+ fileName: e.filename,
1916
+ fileSize: e.size,
1917
+ downloadCount: e.downloadCount,
1918
+ maxDownloads: e.maxDownloads,
1919
+ expiresAt: e.expiresAt,
1920
+ createdAt: e.createdAt
1921
+ }));
1922
+ res.json({ success: true, items });
1923
+ });
1924
+ app.post("/pubshare/revoke", (req, res) => {
1925
+ const { token } = req.body || {};
1926
+ if (!token) return res.status(400).json({ success: false, error: "token required" });
1927
+ const entry = pubsharesByToken.get(token);
1928
+ if (!entry) return res.status(404).json({ success: false, error: "Unknown token" });
1929
+ try {
1930
+ wsConn?.send(JSON.stringify({ type: "pubshare_unregister", payload: { token } }));
1931
+ } catch {
1932
+ }
1933
+ pubsharesByToken.delete(token);
1934
+ pubshares.delete(entry.fileId);
1935
+ res.json({ success: true });
1936
+ });
1937
+ app.post("/mcp", async (req, res) => {
1938
+ try {
1939
+ const message = req.body;
1940
+ if (Array.isArray(message)) {
1941
+ const responses = await Promise.all(message.map((m) => handleMcpMessage(m)));
1942
+ res.json(responses.filter(Boolean));
1943
+ } else {
1944
+ const response = await handleMcpMessage(message);
1945
+ if (response) res.json(response);
1946
+ else res.status(202).end();
1947
+ }
1948
+ } catch (err) {
1949
+ res.status(500).json({ jsonrpc: "2.0", id: null, error: { code: -32603, message: err.message } });
1950
+ }
1951
+ });
1952
+ app.get("/mcp", (req, res) => {
1953
+ res.setHeader("Content-Type", "text/event-stream");
1954
+ res.setHeader("Cache-Control", "no-cache");
1955
+ res.setHeader("Connection", "keep-alive");
1956
+ res.flushHeaders();
1957
+ res.write(`event: endpoint
1958
+ data: ${JSON.stringify({ uri: "/mcp" })}
1959
+
1960
+ `);
1961
+ sseClients.push(res);
1962
+ req.on("close", () => {
1963
+ sseClients = sseClients.filter((c) => c !== res);
1964
+ });
1965
+ });
1966
+ app.get("/mcp/sse", (req, res) => {
1967
+ res.setHeader("Content-Type", "text/event-stream");
1968
+ res.setHeader("Cache-Control", "no-cache");
1969
+ res.setHeader("Connection", "keep-alive");
1970
+ res.flushHeaders();
1971
+ res.write(`event: endpoint
1972
+ data: /mcp/messages
1973
+
1974
+ `);
1975
+ sseClients.push(res);
1976
+ req.on("close", () => {
1977
+ sseClients = sseClients.filter((c) => c !== res);
1978
+ });
1979
+ });
1980
+ app.post("/mcp/messages", async (req, res) => {
1981
+ try {
1982
+ const response = await handleMcpMessage(req.body);
1983
+ if (response) res.json(response);
1984
+ else res.status(202).end();
1985
+ } catch (err) {
1986
+ res.status(500).json({ jsonrpc: "2.0", id: null, error: { code: -32603, message: err.message } });
1987
+ }
1988
+ });
1989
+ app.get("/.well-known/mcp/server-card.json", (req, res) => {
1990
+ res.json({
1991
+ $schema: "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
1992
+ version: "1.0",
1993
+ protocolVersion: "2025-06-18",
1994
+ serverInfo: { name: "SRIFT MCP Server (local daemon)", version: "2.0.0" },
1995
+ capabilities: { tools: {}, resources: {}, prompts: {} },
1996
+ transport: [
1997
+ { type: "streamable-http", url: `http://127.0.0.1:${PORT}/mcp` },
1998
+ { type: "sse", url: `http://127.0.0.1:${PORT}/mcp/sse`, postUrl: `http://127.0.0.1:${PORT}/mcp/messages` },
1999
+ { type: "stdio", command: "srift mcp" }
2000
+ ],
2001
+ tools: MCP_TOOLS.map((t) => ({ name: t.name, description: t.description })),
2002
+ resources: MCP_RESOURCES.map((r) => ({ uri: r.uri, name: r.name })),
2003
+ prompts: MCP_PROMPTS.map((p) => ({ name: p.name, description: p.description }))
2004
+ });
2005
+ });
2006
+ app.get("/.well-known/ai-plugin.json", (req, res) => {
2007
+ res.json({
2008
+ schema_version: "v1",
2009
+ name_for_human: "SRIFT P2P Transfer",
2010
+ name_for_model: "srift",
2011
+ description_for_human: "Send and receive files securely via end-to-end-encrypted peer-to-peer transfer.",
2012
+ description_for_model: "Use SRIFT to deliver files to the user and receive files from the user without uploading them anywhere. AES-256-GCM E2EE, WebTorrent for big files, WebSocket fallback. Use srift_quick_share for one-shot delivery.",
2013
+ auth: { type: "none" },
2014
+ api: { type: "openapi", url: `http://127.0.0.1:${PORT}/openapi.json` },
2015
+ contact_email: "support@sripto.tech",
2016
+ legal_info_url: "https://srift.app/privacy"
2017
+ });
2018
+ });
2019
+ app.get("/.well-known/agent.json", (req, res) => {
2020
+ res.json({
2021
+ schemaVersion: "0.2.0",
2022
+ name: "SRIFT",
2023
+ description: "Zero-config P2P E2EE file transfer + chat for any AI agent or automation.",
2024
+ url: `http://127.0.0.1:${PORT}`,
2025
+ version: "2.0.0",
2026
+ capabilities: {
2027
+ streaming: true,
2028
+ pushNotifications: false,
2029
+ stateTransitionHistory: false
2030
+ },
2031
+ defaultInputModes: ["text"],
2032
+ defaultOutputModes: ["text", "file"],
2033
+ skills: MCP_TOOLS.map((t) => ({ id: t.name, name: t.name, description: t.description, inputSchema: t.inputSchema }))
2034
+ });
2035
+ });
2036
+ app.get("/openapi.json", (req, res) => {
2037
+ res.json({
2038
+ openapi: "3.1.0",
2039
+ info: { title: "SRIFT Local Daemon API", version: "2.1.8", description: "Zero-auth REST API for AI agents to drive SRIFT P2P transfer." },
2040
+ servers: [{ url: `http://127.0.0.1:${PORT}` }],
2041
+ paths: {
2042
+ "/health": { get: { summary: "Liveness probe", description: "Returns {ok,version,uptime_ms,mcp,webrtc,webtorrent}", responses: { "200": { description: "OK" } } } },
2043
+ "/status": { get: { summary: "Get session + transfers + pending joins", responses: { "200": { description: "OK" } } } },
2044
+ "/state": { get: { summary: "Workspace state snapshot (.srift-state.json)", responses: { "200": { description: "OK" } } } },
2045
+ "/transfers": { get: { summary: "Live transfer list with speed and ETA. Optional ?fileId= to filter.", responses: { "200": { description: "OK" } } } },
2046
+ "/transfers/{fileId}": { get: { summary: "Per-transfer drill-down", parameters: [{ name: "fileId", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "OK" }, "404": { description: "Transfer not found" } } } },
2047
+ "/peers": { get: { summary: "Peer connection state, RTT, ICE type", responses: { "200": { description: "OK" } } } },
2048
+ "/metrics": { get: { summary: "Prometheus-format counters (no auth)", responses: { "200": { description: "text/plain Prometheus format" } } } },
2049
+ "/logs": { get: { summary: "NDJSON daemon log tail. ?lines=N (default 100)", responses: { "200": { description: "application/x-ndjson" } } } },
2050
+ "/reset": { post: { summary: "Wipe all state and flush encryption keys", responses: { "200": { description: "{ok:true}" } } } },
2051
+ "/session/start": { post: { summary: "Create new session (becomes host)", requestBody: { content: { "application/json": { schema: { type: "object", properties: { sessionName: { type: "string" }, roomSecret: { type: "string" } } } } } }, responses: { "200": { description: "{success:true,sessionId}" } } } },
2052
+ "/session/join": { post: { summary: "Join existing session", requestBody: { content: { "application/json": { schema: { type: "object", required: ["sessionId"], properties: { sessionId: { type: "string" }, username: { type: "string" }, roomSecret: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "Missing sessionId" }, "404": { description: "Session not found" } } } },
2053
+ "/session/approve": { post: { summary: "Host approves a pending join request", requestBody: { content: { "application/json": { schema: { type: "object", required: ["tempUserId"], properties: { tempUserId: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "tempUserId required" }, "403": { description: "Not host" }, "404": { description: "No such pending request" } } } },
2054
+ "/session/reject": { post: { summary: "Host rejects a pending join request", requestBody: { content: { "application/json": { schema: { type: "object", required: ["tempUserId"], properties: { tempUserId: { type: "string" }, reason: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "tempUserId required" }, "403": { description: "Not host" } } } },
2055
+ "/session/kick": { post: { summary: "Host kicks a peer", requestBody: { content: { "application/json": { schema: { type: "object", required: ["userId"], properties: { userId: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "userId required" }, "403": { description: "Not host" } } } },
2056
+ "/session/close": { post: { summary: "Tear down the session and flush keys", responses: { "200": { description: "OK" } } } },
2057
+ "/send": { post: { summary: "Offer a file to peers (requires active session)", requestBody: { content: { "application/json": { schema: { type: "object", required: ["filePath"], properties: { filePath: { type: "string" } } } } } }, responses: { "200": { description: "{success:true,fileId}" } } } },
2058
+ "/receive": { post: { summary: "Accept an incoming file offer", requestBody: { content: { "application/json": { schema: { type: "object", required: ["fileId"], properties: { fileId: { type: "string" }, saveDir: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "404": { description: "No offer for this fileId" } } } },
2059
+ "/quick-share": { post: { summary: "ONE-SHOT: create session if needed, register the file for public HTTPS download, and return a direct downloadUrl (https://srift.app/d/<token>) the recipient can curl/wget/browse with zero install.", requestBody: { content: { "application/json": { schema: { type: "object", required: ["filePath"], properties: { filePath: { type: "string", description: "Absolute path to file" }, sessionName: { type: "string" } } } } } }, responses: { "200": { description: "{success,sessionId,fileId,downloadUrl,shareUrl,fileName,fileSize}" } } } },
2060
+ "/chat/send": { post: { summary: "Send E2EE chat message", requestBody: { content: { "application/json": { schema: { type: "object", required: ["message"], properties: { message: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "message required" }, "409": { description: "No session" }, "503": { description: "WS not ready \u2014 retry in 1\u20132s" } } } },
2061
+ "/chat/history": { get: { summary: "Decrypted chat log", responses: { "200": { description: "Array of {messageId,sender,content,timestamp}" } } } },
2062
+ "/api/v1/monitor/events": { get: { summary: "SSE event stream: connection_state, join_request, file_offer, transfer_progress, chat_received", responses: { "200": { description: "text/event-stream" } } } },
2063
+ "/mcp": { post: { summary: "MCP JSON-RPC (Streamable HTTP, spec 2025-06-18)", responses: { "200": { description: "JSON-RPC response" } } }, get: { summary: "SSE stream for server\u2192client MCP notifications", responses: { "200": { description: "text/event-stream" } } } },
2064
+ "/mcp/sse": { get: { summary: "Legacy SSE MCP endpoint (older clients)", responses: { "200": { description: "text/event-stream" } } } },
2065
+ "/mcp/messages": { post: { summary: "Legacy SSE MCP message submission endpoint", responses: { "200": { description: "OK" } } } }
2066
+ }
2067
+ });
2068
+ });
2069
+ app.get("/transfers", (req, res) => {
2070
+ const fileId = req.query.fileId;
2071
+ const list = fileId ? activeTransfers.filter((t) => t.fileId === fileId) : activeTransfers;
2072
+ res.json(list.map((t) => ({
2073
+ fileId: t.fileId,
2074
+ name: t.name,
2075
+ size: t.size,
2076
+ progress: parseFloat(t.progress.toFixed(2)),
2077
+ speedKBps: parseFloat(t.speedKBps.toFixed(2)),
2078
+ etaSeconds: Math.ceil(t.etaSeconds),
2079
+ protocol: t.protocol,
2080
+ status: t.status,
2081
+ direction: t.direction,
2082
+ bytesTransferred: t.bytesTransferred
2083
+ })));
2084
+ });
2085
+ app.get("/transfers/:fileId", (req, res) => {
2086
+ const tx = activeTransfers.find((t) => t.fileId === req.params.fileId);
2087
+ if (!tx) return res.status(404).json({ error: "Transfer not found" });
2088
+ res.json(tx);
2089
+ });
2090
+ app.get("/peers", (req, res) => {
2091
+ if (!session.id) return res.json([]);
2092
+ const state = wsConn ? wsConn.readyState : 3;
2093
+ const stateNames = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
2094
+ const peer = {
2095
+ sessionId: session.id,
2096
+ userId: session.userId,
2097
+ role: session.role,
2098
+ connectionState: stateNames[state] || "UNKNOWN",
2099
+ transport: "websocket",
2100
+ rttMs: null,
2101
+ iceType: "relay",
2102
+ isConnected: session.isConnected
2103
+ };
2104
+ res.json(session.id ? [peer] : []);
2105
+ });
2106
+ app.get("/metrics", (req, res) => {
2107
+ const uptime = ((Date.now() - DAEMON_START_TIME) / 1e3).toFixed(0);
2108
+ const lines = [
2109
+ "# HELP srift_uptime_seconds Daemon uptime in seconds",
2110
+ "# TYPE srift_uptime_seconds gauge",
2111
+ `srift_uptime_seconds ${uptime}`,
2112
+ "# HELP srift_active_transfers Number of active file transfers",
2113
+ "# TYPE srift_active_transfers gauge",
2114
+ `srift_active_transfers ${activeTransfers.filter((t) => t.status === "uploading" || t.status === "downloading").length}`,
2115
+ "# HELP srift_total_transfers Total transfers (all statuses)",
2116
+ "# TYPE srift_total_transfers counter",
2117
+ `srift_total_transfers ${activeTransfers.length}`,
2118
+ "# HELP srift_chat_messages_total Total chat messages in history",
2119
+ "# TYPE srift_chat_messages_total counter",
2120
+ `srift_chat_messages_total ${chatHistory.length}`,
2121
+ "# HELP srift_session_active Whether a session is currently active (1 = yes)",
2122
+ "# TYPE srift_session_active gauge",
2123
+ `srift_session_active ${session.id ? 1 : 0}`,
2124
+ "# HELP srift_ws_connected Whether the WebSocket is connected (1 = yes)",
2125
+ "# TYPE srift_ws_connected gauge",
2126
+ `srift_ws_connected ${session.isConnected ? 1 : 0}`
2127
+ ];
2128
+ res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
2129
+ res.send(lines.join("\n") + "\n");
2130
+ });
2131
+ app.get("/logs", (req, res) => {
2132
+ const lines = parseInt(req.query.lines || "100", 10);
2133
+ try {
2134
+ if (!fs.existsSync(logPath)) return res.json([]);
2135
+ const content = fs.readFileSync(logPath, "utf-8");
2136
+ const all = content.split("\n").filter(Boolean);
2137
+ const tail = all.slice(-Math.min(lines, all.length));
2138
+ res.set("Content-Type", "application/x-ndjson");
2139
+ res.send(tail.join("\n"));
2140
+ } catch (err) {
2141
+ res.status(500).json({ error: err.message });
2142
+ }
2143
+ });
2144
+ app.post("/reset", (req, res) => {
2145
+ console.log("[DAEMON] /reset: full wipe (memory + disk)");
2146
+ if (wsConn) {
2147
+ try {
2148
+ wsConn.close();
2149
+ } catch {
2150
+ }
2151
+ wsConn = null;
2152
+ }
2153
+ if (heartbeatInterval) {
2154
+ clearInterval(heartbeatInterval);
2155
+ heartbeatInterval = null;
2156
+ }
2157
+ if (wtClient) {
2158
+ try {
2159
+ wtClient.destroy();
2160
+ } catch {
2161
+ }
2162
+ wtClient = null;
2163
+ }
2164
+ activeTorrents.clear();
2165
+ activeUploads.clear();
2166
+ session = { id: null, name: null, role: null, isConnected: false, userId: null };
2167
+ activeTransfers = [];
2168
+ chatHistory = [];
2169
+ pendingJoins = [];
2170
+ csrfToken = null;
2171
+ encryptionKey = null;
2172
+ wsUrl = null;
2173
+ let cleanedChunks = 0;
2174
+ try {
2175
+ if (fs.existsSync(TEMP_DIR)) {
2176
+ for (const f of fs.readdirSync(TEMP_DIR)) {
2177
+ try {
2178
+ fs.unlinkSync(path.join(TEMP_DIR, f));
2179
+ cleanedChunks++;
2180
+ } catch {
2181
+ }
2182
+ }
2183
+ }
2184
+ } catch {
2185
+ }
2186
+ writeStateFile();
2187
+ res.json({ ok: true, message: "State wiped, keys flushed, temp chunks cleaned", cleanedChunks });
2188
+ });
2189
+ app.get("/api/v1/monitor/events", (req, res) => {
2190
+ res.setHeader("Content-Type", "text/event-stream");
2191
+ res.setHeader("Cache-Control", "no-cache");
2192
+ res.setHeader("Connection", "keep-alive");
2193
+ res.flushHeaders();
2194
+ sseClients.push(res);
2195
+ console.log(`[DAEMON] SSE Client connected. Active clients: ${sseClients.length}`);
2196
+ req.on("close", () => {
2197
+ sseClients = sseClients.filter((c) => c !== res);
2198
+ console.log(`[DAEMON] SSE Client disconnected. Active clients: ${sseClients.length}`);
2199
+ });
2200
+ });
2201
+ app.post("/daemon/stop", (req, res) => {
2202
+ console.log("[DAEMON] Stop request received. Exiting daemon process gracefully...");
2203
+ res.json({ success: true });
2204
+ try {
2205
+ if (wsConn) {
2206
+ wsConn.close();
2207
+ wsConn = null;
2208
+ }
2209
+ } catch {
2210
+ }
2211
+ try {
2212
+ if (heartbeatInterval) {
2213
+ clearInterval(heartbeatInterval);
2214
+ heartbeatInterval = null;
2215
+ }
2216
+ } catch {
2217
+ }
2218
+ try {
2219
+ if (wtClient) {
2220
+ wtClient.destroy();
2221
+ wtClient = null;
2222
+ }
2223
+ } catch {
2224
+ }
2225
+ try {
2226
+ activeTorrents.clear();
2227
+ activeUploads.clear();
2228
+ } catch {
2229
+ }
2230
+ try {
2231
+ session = { id: null, name: null, role: null, isConnected: false, userId: null };
2232
+ activeTransfers = [];
2233
+ chatHistory = [];
2234
+ pendingJoins = [];
2235
+ encryptionKey = null;
2236
+ wsUrl = null;
2237
+ writeStateFile();
2238
+ } catch {
2239
+ }
2240
+ try {
2241
+ if (fs.existsSync(TEMP_DIR)) {
2242
+ for (const f of fs.readdirSync(TEMP_DIR)) {
2243
+ try {
2244
+ fs.unlinkSync(path.join(TEMP_DIR, f));
2245
+ } catch {
2246
+ }
2247
+ }
2248
+ }
2249
+ } catch {
2250
+ }
2251
+ setTimeout(() => {
2252
+ process.exit(0);
2253
+ }, 500);
2254
+ });
2255
+ app.listen(PORT, "127.0.0.1", async () => {
2256
+ await selectSignalerUrl();
2257
+ console.log(`[DAEMON] Background daemon listening on http://127.0.0.1:${PORT}`);
2258
+ writeStateFile();
2259
+ });