clay-server 2.11.0-beta.8 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +282 -115
- package/lib/daemon.js +109 -37
- package/lib/os-users.js +58 -1
- package/lib/pages.js +31 -29
- package/lib/project.js +47 -9
- package/lib/public/app.js +158 -16
- package/lib/public/css/filebrowser.css +6 -0
- package/lib/public/css/icon-strip.css +123 -1
- package/lib/public/css/messages.css +1 -1
- package/lib/public/css/mobile-nav.css +17 -0
- package/lib/public/css/overlays.css +49 -0
- package/lib/public/css/sidebar.css +26 -0
- package/lib/public/css/sticky-notes.css +3 -0
- package/lib/public/index.html +2 -0
- package/lib/public/modules/admin.js +53 -5
- package/lib/public/modules/sidebar.js +299 -21
- package/lib/public/modules/sticky-notes.js +27 -5
- package/lib/public/modules/terminal.js +161 -25
- package/lib/sdk-bridge.js +53 -7
- package/lib/server.js +156 -17
- package/lib/sessions.js +48 -7
- package/lib/terminal-manager.js +4 -2
- package/lib/terminal.js +2 -1
- package/lib/users.js +92 -0
- package/package.json +1 -1
|
@@ -15,6 +15,106 @@ var resizeObserver = null;
|
|
|
15
15
|
var toolbarBound = false;
|
|
16
16
|
var termCtxMenu = null;
|
|
17
17
|
|
|
18
|
+
// --- Multi-line link provider ---
|
|
19
|
+
// xterm's WebLinksAddon only detects URLs on a single line.
|
|
20
|
+
// This provider reconstructs "logical lines" from wrapped buffer lines
|
|
21
|
+
// and detects URLs that span multiple rows.
|
|
22
|
+
function createMultiLineLinkProvider(xterm) {
|
|
23
|
+
var URL_RE = /https?:\/\/[^\s'"\]>)}{]+/g;
|
|
24
|
+
|
|
25
|
+
function getLogicalLine(buffer, y) {
|
|
26
|
+
// Walk backward to find the start of the logical line
|
|
27
|
+
var startY = y;
|
|
28
|
+
while (startY > 0) {
|
|
29
|
+
var line = buffer.getLine(startY);
|
|
30
|
+
if (!line || !line.isWrapped) break;
|
|
31
|
+
startY--;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Walk forward to collect all wrapped continuation lines
|
|
35
|
+
var endY = startY;
|
|
36
|
+
var cols = xterm.cols;
|
|
37
|
+
while (endY < buffer.length - 1) {
|
|
38
|
+
var next = buffer.getLine(endY + 1);
|
|
39
|
+
if (!next || !next.isWrapped) break;
|
|
40
|
+
endY++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Build the full logical line text and track row boundaries
|
|
44
|
+
var text = "";
|
|
45
|
+
var rowOffsets = []; // { y, startOffset, length }
|
|
46
|
+
for (var row = startY; row <= endY; row++) {
|
|
47
|
+
var line = buffer.getLine(row);
|
|
48
|
+
if (!line) break;
|
|
49
|
+
var trimRight = (row === endY); // only trim trailing spaces on last row
|
|
50
|
+
var rowText = line.translateToString(trimRight);
|
|
51
|
+
rowOffsets.push({ y: row, startOffset: text.length, length: rowText.length });
|
|
52
|
+
text += rowText;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { text: text, startY: startY, endY: endY, rowOffsets: rowOffsets };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function offsetToBufferPos(rowOffsets, offset) {
|
|
59
|
+
for (var i = 0; i < rowOffsets.length; i++) {
|
|
60
|
+
var ro = rowOffsets[i];
|
|
61
|
+
if (offset < ro.startOffset + ro.length) {
|
|
62
|
+
return { x: offset - ro.startOffset + 1, y: ro.y + 1 }; // 1-based
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Past end, clamp to last row
|
|
66
|
+
var last = rowOffsets[rowOffsets.length - 1];
|
|
67
|
+
return { x: last.length + 1, y: last.y + 1 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
provideLinks: function (y, callback) {
|
|
72
|
+
var buffer = xterm.buffer.active;
|
|
73
|
+
// y is 1-based in provideLinks
|
|
74
|
+
var bufferY = y - 1;
|
|
75
|
+
var logical = getLogicalLine(buffer, bufferY);
|
|
76
|
+
|
|
77
|
+
// Only process if this logical line spans multiple rows
|
|
78
|
+
if (logical.startY === logical.endY) {
|
|
79
|
+
callback(undefined);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only trigger on the first row of the logical line to avoid duplicates
|
|
84
|
+
if (bufferY !== logical.startY) {
|
|
85
|
+
callback(undefined);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var links = [];
|
|
90
|
+
var match;
|
|
91
|
+
URL_RE.lastIndex = 0;
|
|
92
|
+
while ((match = URL_RE.exec(logical.text)) !== null) {
|
|
93
|
+
var urlStart = match.index;
|
|
94
|
+
var urlEnd = match.index + match[0].length - 1;
|
|
95
|
+
|
|
96
|
+
var startPos = offsetToBufferPos(logical.rowOffsets, urlStart);
|
|
97
|
+
var endPos = offsetToBufferPos(logical.rowOffsets, urlEnd);
|
|
98
|
+
|
|
99
|
+
// Only include if the URL actually spans multiple rows
|
|
100
|
+
if (startPos.y !== endPos.y) {
|
|
101
|
+
(function (url) {
|
|
102
|
+
links.push({
|
|
103
|
+
range: { start: startPos, end: endPos },
|
|
104
|
+
text: url,
|
|
105
|
+
activate: function () {
|
|
106
|
+
window.open(url, "_blank", "noopener");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
})(match[0]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
callback(links.length > 0 ? links : undefined);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
18
118
|
// --- Init ---
|
|
19
119
|
export function initTerminal(_ctx) {
|
|
20
120
|
ctx = _ctx;
|
|
@@ -184,6 +284,8 @@ function activateTab(termId) {
|
|
|
184
284
|
// Fit and focus
|
|
185
285
|
setupListeners();
|
|
186
286
|
fitTerminal();
|
|
287
|
+
// Re-fit after layout settles (flex may not have computed final size yet)
|
|
288
|
+
setTimeout(fitTerminal, 50);
|
|
187
289
|
|
|
188
290
|
if (tab.xterm) {
|
|
189
291
|
tab.xterm.focus();
|
|
@@ -225,6 +327,14 @@ function createXtermForTab(tab) {
|
|
|
225
327
|
xterm.loadAddon(fitAddon);
|
|
226
328
|
}
|
|
227
329
|
|
|
330
|
+
// Web links addon: make URLs clickable (single-line)
|
|
331
|
+
if (typeof WebLinksAddon !== "undefined") {
|
|
332
|
+
xterm.loadAddon(new WebLinksAddon.WebLinksAddon());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Custom multi-line link provider: detect URLs that wrap across lines
|
|
336
|
+
xterm.registerLinkProvider(createMultiLineLinkProvider(xterm));
|
|
337
|
+
|
|
228
338
|
// Create a container div for this tab's terminal
|
|
229
339
|
var bodyEl = document.createElement("div");
|
|
230
340
|
bodyEl.className = "terminal-tab-body";
|
|
@@ -239,20 +349,26 @@ function createXtermForTab(tab) {
|
|
|
239
349
|
}
|
|
240
350
|
});
|
|
241
351
|
|
|
242
|
-
// Cmd/Ctrl+C copy:
|
|
243
|
-
|
|
352
|
+
// Cmd/Ctrl+C copy and Cmd/Ctrl+V paste: intercept before xterm swallows the event
|
|
353
|
+
xterm.attachCustomKeyEventHandler(function (e) {
|
|
354
|
+
if (e.type !== "keydown") return true;
|
|
355
|
+
// Cmd/Ctrl+C: copy selection if any, otherwise send SIGINT
|
|
244
356
|
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
|
245
|
-
var sel =
|
|
357
|
+
var sel = xterm.getSelection();
|
|
246
358
|
if (sel) {
|
|
247
|
-
e.preventDefault();
|
|
248
359
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
249
360
|
navigator.clipboard.writeText(sel).catch(function () {});
|
|
250
361
|
}
|
|
362
|
+
return false; // prevent xterm from handling
|
|
251
363
|
}
|
|
252
|
-
//
|
|
364
|
+
// No selection on macOS Cmd+C: do nothing (not SIGINT)
|
|
365
|
+
if (e.metaKey) return false;
|
|
253
366
|
}
|
|
254
|
-
//
|
|
255
|
-
|
|
367
|
+
// Cmd/Ctrl+V: let browser handle paste event
|
|
368
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
|
369
|
+
return false; // let browser fire paste event
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
256
372
|
});
|
|
257
373
|
|
|
258
374
|
// Handle paste via browser paste event (works for Cmd+V, Ctrl+V, right-click paste)
|
|
@@ -277,22 +393,28 @@ function createXtermForTab(tab) {
|
|
|
277
393
|
}
|
|
278
394
|
|
|
279
395
|
// --- Fit active terminal ---
|
|
280
|
-
|
|
281
|
-
if (!activeTabId) return;
|
|
282
|
-
var tab = tabs.get(activeTabId);
|
|
283
|
-
if (!tab || !tab.fitAddon || !tab.xterm) return;
|
|
396
|
+
var fitRafId = null;
|
|
284
397
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
398
|
+
function fitTerminal() {
|
|
399
|
+
if (fitRafId) cancelAnimationFrame(fitRafId);
|
|
400
|
+
fitRafId = requestAnimationFrame(function () {
|
|
401
|
+
fitRafId = null;
|
|
402
|
+
if (!activeTabId) return;
|
|
403
|
+
var tab = tabs.get(activeTabId);
|
|
404
|
+
if (!tab || !tab.fitAddon || !tab.xterm) return;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
tab.fitAddon.fit();
|
|
408
|
+
if (ctx.ws && ctx.connected) {
|
|
409
|
+
ctx.ws.send(JSON.stringify({
|
|
410
|
+
type: "term_resize",
|
|
411
|
+
id: activeTabId,
|
|
412
|
+
cols: tab.xterm.cols,
|
|
413
|
+
rows: tab.xterm.rows,
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {}
|
|
417
|
+
});
|
|
296
418
|
}
|
|
297
419
|
|
|
298
420
|
// --- Setup/cleanup resize listeners ---
|
|
@@ -611,10 +733,24 @@ function showTermCtxMenu(e, tab) {
|
|
|
611
733
|
var menu = document.createElement("div");
|
|
612
734
|
menu.className = "term-ctx-menu";
|
|
613
735
|
|
|
614
|
-
// Copy
|
|
736
|
+
// Copy selection
|
|
737
|
+
var sel = tab.xterm ? tab.xterm.getSelection() : "";
|
|
738
|
+
if (sel) {
|
|
739
|
+
var copySelItem = document.createElement("button");
|
|
740
|
+
copySelItem.className = "term-ctx-item";
|
|
741
|
+
copySelItem.innerHTML = iconHtml("copy") + " <span>Copy</span>";
|
|
742
|
+
copySelItem.addEventListener("click", function (ev) {
|
|
743
|
+
ev.stopPropagation();
|
|
744
|
+
closeTermCtxMenu();
|
|
745
|
+
if (sel) copyToClipboard(sel);
|
|
746
|
+
});
|
|
747
|
+
menu.appendChild(copySelItem);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Copy entire console
|
|
615
751
|
var copyItem = document.createElement("button");
|
|
616
752
|
copyItem.className = "term-ctx-item";
|
|
617
|
-
copyItem.innerHTML = iconHtml("clipboard-copy") + " <span>Copy
|
|
753
|
+
copyItem.innerHTML = iconHtml("clipboard-copy") + " <span>Copy Console</span>";
|
|
618
754
|
copyItem.addEventListener("click", function (ev) {
|
|
619
755
|
ev.stopPropagation();
|
|
620
756
|
closeTermCtxMenu();
|
|
@@ -647,7 +783,7 @@ function showTermCtxMenu(e, tab) {
|
|
|
647
783
|
// Clear
|
|
648
784
|
var clearItem = document.createElement("button");
|
|
649
785
|
clearItem.className = "term-ctx-item";
|
|
650
|
-
clearItem.innerHTML = iconHtml("trash-2") + " <span>Clear
|
|
786
|
+
clearItem.innerHTML = iconHtml("trash-2") + " <span>Clear Console</span>";
|
|
651
787
|
clearItem.addEventListener("click", function (ev) {
|
|
652
788
|
ev.stopPropagation();
|
|
653
789
|
closeTermCtxMenu();
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -573,6 +573,38 @@ function createSDKBridge(opts) {
|
|
|
573
573
|
|
|
574
574
|
var WORKER_SCRIPT = path.join(__dirname, "sdk-worker.js");
|
|
575
575
|
|
|
576
|
+
// Ensure the package directory tree is world-readable so OS-level users
|
|
577
|
+
// can access the worker script and its dependencies (the install path
|
|
578
|
+
// may be under /root/.npm/_npx/ which defaults to 700)
|
|
579
|
+
(function ensurePackageReadable() {
|
|
580
|
+
try {
|
|
581
|
+
// Walk up from __dirname to find the package root (where node_modules lives)
|
|
582
|
+
var pkgDir = path.join(__dirname, "..");
|
|
583
|
+
// Open read+execute on each ancestor directory up to and including the
|
|
584
|
+
// npx cache entry so that non-root users can traverse the path
|
|
585
|
+
var dir = pkgDir;
|
|
586
|
+
var dirs = [];
|
|
587
|
+
while (dir !== path.dirname(dir)) {
|
|
588
|
+
dirs.push(dir);
|
|
589
|
+
// Stop once we leave the npm cache tree
|
|
590
|
+
if (dir.indexOf(".npm") === -1 && dir.indexOf("node_modules") === -1) break;
|
|
591
|
+
dir = path.dirname(dir);
|
|
592
|
+
}
|
|
593
|
+
for (var di = 0; di < dirs.length; di++) {
|
|
594
|
+
try {
|
|
595
|
+
var st = fs.statSync(dirs[di]);
|
|
596
|
+
// Add o+rx if not already present
|
|
597
|
+
if ((st.mode & 0o005) !== 0o005) {
|
|
598
|
+
fs.chmodSync(dirs[di], st.mode | 0o005);
|
|
599
|
+
}
|
|
600
|
+
} catch (e) {}
|
|
601
|
+
}
|
|
602
|
+
// Recursively make the package contents readable
|
|
603
|
+
var { execSync: chmodExec } = require("child_process");
|
|
604
|
+
chmodExec("chmod -R o+rX " + JSON.stringify(pkgDir), { stdio: "ignore", timeout: 5000 });
|
|
605
|
+
} catch (e) {}
|
|
606
|
+
})();
|
|
607
|
+
|
|
576
608
|
// resolveLinuxUser delegates to shared os-users utility
|
|
577
609
|
function resolveLinuxUser(username) {
|
|
578
610
|
return resolveOsUserInfo(username);
|
|
@@ -658,12 +690,27 @@ function createSDKBridge(opts) {
|
|
|
658
690
|
worker.process.stdout.on("data", function(data) {
|
|
659
691
|
console.log("[sdk-worker:" + linuxUser + "] " + data.toString().trim());
|
|
660
692
|
});
|
|
693
|
+
worker._stderrBuf = "";
|
|
661
694
|
worker.process.stderr.on("data", function(data) {
|
|
662
|
-
|
|
695
|
+
var text = data.toString().trim();
|
|
696
|
+
worker._stderrBuf += text + "\n";
|
|
697
|
+
console.error("[sdk-worker:" + linuxUser + "] " + text);
|
|
663
698
|
});
|
|
664
699
|
|
|
665
700
|
worker.process.on("exit", function(code, signal) {
|
|
666
701
|
console.log("[sdk-bridge] Worker for " + linuxUser + " exited (code=" + code + ", signal=" + signal + ")");
|
|
702
|
+
// Notify message handlers about unexpected exit so sessions don't hang
|
|
703
|
+
if (code !== 0 && code !== null) {
|
|
704
|
+
var stderrText = worker._stderrBuf || "";
|
|
705
|
+
for (var h = 0; h < worker.messageHandlers.length; h++) {
|
|
706
|
+
worker.messageHandlers[h]({
|
|
707
|
+
type: "query_error",
|
|
708
|
+
error: stderrText || "Worker exited with code " + code,
|
|
709
|
+
exitCode: code,
|
|
710
|
+
stderr: stderrText || null,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
667
714
|
cleanupWorker(worker);
|
|
668
715
|
});
|
|
669
716
|
});
|
|
@@ -1021,7 +1068,7 @@ function createSDKBridge(opts) {
|
|
|
1021
1068
|
worker.kill();
|
|
1022
1069
|
} else if (msg.type === "warmup_error" && !warmupDone) {
|
|
1023
1070
|
warmupDone = true;
|
|
1024
|
-
send({ type: "error", text:
|
|
1071
|
+
send({ type: "error", text: msg.error || "Warmup failed" });
|
|
1025
1072
|
worker.kill();
|
|
1026
1073
|
}
|
|
1027
1074
|
});
|
|
@@ -1152,14 +1199,13 @@ function createSDKBridge(opts) {
|
|
|
1152
1199
|
for (var j = 0; j < candidates.length; j++) {
|
|
1153
1200
|
var c = candidates[j];
|
|
1154
1201
|
try {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
if (cwdMatch && cwdMatch[1] === cwd) {
|
|
1202
|
+
// Use /proc/<pid>/cwd symlink (always available on Linux, no lsof dependency)
|
|
1203
|
+
var procCwd = fs.readlinkSync("/proc/" + c.pid + "/cwd");
|
|
1204
|
+
if (procCwd === cwd) {
|
|
1159
1205
|
results.push(c);
|
|
1160
1206
|
}
|
|
1161
1207
|
} catch (e) {
|
|
1162
|
-
//
|
|
1208
|
+
// /proc read failed — include as candidate anyway (conservative)
|
|
1163
1209
|
results.push(c);
|
|
1164
1210
|
}
|
|
1165
1211
|
}
|
package/lib/server.js
CHANGED
|
@@ -353,6 +353,7 @@ function createServer(opts) {
|
|
|
353
353
|
var onSetPin = opts.onSetPin || null;
|
|
354
354
|
var onSetKeepAwake = opts.onSetKeepAwake || null;
|
|
355
355
|
var onShutdown = opts.onShutdown || null;
|
|
356
|
+
var onRestart = opts.onRestart || null;
|
|
356
357
|
var onSetUpdateChannel = opts.onSetUpdateChannel || null;
|
|
357
358
|
var onUpgradePin = opts.onUpgradePin || null;
|
|
358
359
|
var onSetProjectVisibility = opts.onSetProjectVisibility || null;
|
|
@@ -360,6 +361,7 @@ function createServer(opts) {
|
|
|
360
361
|
var onGetProjectAccess = opts.onGetProjectAccess || null;
|
|
361
362
|
var onUserProvisioned = opts.onUserProvisioned || null;
|
|
362
363
|
var onUserDeleted = opts.onUserDeleted || null;
|
|
364
|
+
var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
|
|
363
365
|
|
|
364
366
|
var authToken = pinHash || null;
|
|
365
367
|
var realVersion = require("../package.json").version;
|
|
@@ -1118,6 +1120,48 @@ function createServer(opts) {
|
|
|
1118
1120
|
return;
|
|
1119
1121
|
}
|
|
1120
1122
|
|
|
1123
|
+
// Reset user PIN (admin only) — generates a new temp PIN
|
|
1124
|
+
if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
|
|
1125
|
+
if (!users.isMultiUser()) {
|
|
1126
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1127
|
+
res.end('{"error":"Not found"}');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
var mu = getMultiUserFromReq(req);
|
|
1131
|
+
if (!mu || mu.role !== "admin") {
|
|
1132
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1133
|
+
res.end('{"error":"Admin access required"}');
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
var urlParts = fullUrl.split("/");
|
|
1137
|
+
var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
|
|
1138
|
+
var targetUser = users.findUserById(targetUserId);
|
|
1139
|
+
if (!targetUser) {
|
|
1140
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1141
|
+
res.end('{"error":"User not found"}');
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
var newPin = users.generatePin();
|
|
1145
|
+
var pinResult = users.updateUserPin(targetUserId, newPin);
|
|
1146
|
+
if (pinResult.error) {
|
|
1147
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1148
|
+
res.end(JSON.stringify({ error: pinResult.error }));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
// Mark as must change on next login
|
|
1152
|
+
var data = users.loadUsers();
|
|
1153
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
1154
|
+
if (data.users[i].id === targetUserId) {
|
|
1155
|
+
data.users[i].mustChangePin = true;
|
|
1156
|
+
users.saveUsers(data);
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1161
|
+
res.end(JSON.stringify({ ok: true, tempPin: newPin }));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1121
1165
|
// Set Linux user mapping (admin only, OS-level multi-user)
|
|
1122
1166
|
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
|
|
1123
1167
|
if (!users.isMultiUser()) {
|
|
@@ -1920,6 +1964,7 @@ function createServer(opts) {
|
|
|
1920
1964
|
|
|
1921
1965
|
var ctx = projects.get(wsSlug);
|
|
1922
1966
|
if (!ctx) {
|
|
1967
|
+
if (debug) console.log("[server] WS rejected: project not found for slug", wsSlug);
|
|
1923
1968
|
socket.destroy();
|
|
1924
1969
|
return;
|
|
1925
1970
|
}
|
|
@@ -1931,8 +1976,10 @@ function createServer(opts) {
|
|
|
1931
1976
|
// Check project access for multi-user mode
|
|
1932
1977
|
if (wsUser && onGetProjectAccess) {
|
|
1933
1978
|
var projectAccess = onGetProjectAccess(wsSlug);
|
|
1979
|
+
if (debug) console.log("[server] WS access check:", wsSlug, "user:", wsUser.id, "role:", wsUser.role, "visibility:", projectAccess && projectAccess.visibility, "ownerId:", projectAccess && projectAccess.ownerId, "allowed:", projectAccess && projectAccess.allowedUsers);
|
|
1934
1980
|
if (projectAccess && !projectAccess.error) {
|
|
1935
1981
|
if (!users.canAccessProject(wsUser.id, projectAccess)) {
|
|
1982
|
+
if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
|
|
1936
1983
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
1937
1984
|
socket.destroy();
|
|
1938
1985
|
return;
|
|
@@ -1966,23 +2013,51 @@ function createServer(opts) {
|
|
|
1966
2013
|
return origEmit.apply(ws, arguments);
|
|
1967
2014
|
};
|
|
1968
2015
|
ws._clayUser = wsUser; // attach user context
|
|
2016
|
+
// Clear cross-project unread for this project when client connects
|
|
2017
|
+
var unreadMap = getCrossProjectUnread(ws);
|
|
2018
|
+
if (unreadMap[wsSlug]) {
|
|
2019
|
+
unreadMap[wsSlug] = 0;
|
|
2020
|
+
}
|
|
1969
2021
|
ctx.handleConnection(ws, wsUser);
|
|
1970
2022
|
});
|
|
1971
2023
|
});
|
|
1972
2024
|
|
|
2025
|
+
// --- Cross-project unread tracking ---
|
|
2026
|
+
// WeakMap<ws, { slug: count }> tracks how many done events happened in other projects
|
|
2027
|
+
var crossProjectUnread = new WeakMap();
|
|
2028
|
+
|
|
2029
|
+
function getCrossProjectUnread(ws) {
|
|
2030
|
+
var map = crossProjectUnread.get(ws);
|
|
2031
|
+
if (!map) { map = {}; crossProjectUnread.set(ws, map); }
|
|
2032
|
+
return map;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function onSessionDone(sourceSlug) {
|
|
2036
|
+
// Increment unread for all clients NOT connected to sourceSlug
|
|
2037
|
+
projects.forEach(function (ctx, projSlug) {
|
|
2038
|
+
if (projSlug === sourceSlug) return;
|
|
2039
|
+
ctx.forEachClient(function (ws) {
|
|
2040
|
+
var map = getCrossProjectUnread(ws);
|
|
2041
|
+
map[sourceSlug] = (map[sourceSlug] || 0) + 1;
|
|
2042
|
+
});
|
|
2043
|
+
});
|
|
2044
|
+
// Trigger a projects_updated broadcast so clients get updated unread counts
|
|
2045
|
+
broadcastProcessingChange();
|
|
2046
|
+
}
|
|
2047
|
+
|
|
1973
2048
|
// --- Debounced broadcast for processing status changes ---
|
|
1974
2049
|
var processingUpdateTimer = null;
|
|
1975
2050
|
function broadcastProcessingChange() {
|
|
1976
2051
|
if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
|
|
1977
2052
|
processingUpdateTimer = setTimeout(function () {
|
|
1978
2053
|
processingUpdateTimer = null;
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2054
|
+
var allProjectsList = getProjects();
|
|
2055
|
+
// Always send per-client to include cross-project unread counts
|
|
2056
|
+
projects.forEach(function (ctx, projSlug) {
|
|
2057
|
+
ctx.forEachClient(function (ws) {
|
|
2058
|
+
var filtered = allProjectsList;
|
|
2059
|
+
if (users.isMultiUser() && onGetProjectAccess) {
|
|
1984
2060
|
var wsUser = ws._clayUser;
|
|
1985
|
-
var filtered = allProjectsList;
|
|
1986
2061
|
if (wsUser) {
|
|
1987
2062
|
filtered = allProjectsList.filter(function (p) {
|
|
1988
2063
|
var access = onGetProjectAccess(p.slug);
|
|
@@ -1990,20 +2065,31 @@ function createServer(opts) {
|
|
|
1990
2065
|
return users.canAccessProject(wsUser.id, access);
|
|
1991
2066
|
});
|
|
1992
2067
|
}
|
|
2068
|
+
}
|
|
2069
|
+
// Attach per-project unread counts for this client
|
|
2070
|
+
var unreadMap = getCrossProjectUnread(ws);
|
|
2071
|
+
var projectsWithUnread = filtered.map(function (p) {
|
|
2072
|
+
var copy = {};
|
|
2073
|
+
var keys = Object.keys(p);
|
|
2074
|
+
for (var i = 0; i < keys.length; i++) copy[keys[i]] = p[keys[i]];
|
|
2075
|
+
// For the current project, use session-level unread total
|
|
2076
|
+
if (p.slug === projSlug) {
|
|
2077
|
+
copy.unread = ctx.sm.getTotalUnread(ws);
|
|
2078
|
+
} else {
|
|
2079
|
+
copy.unread = unreadMap[p.slug] || 0;
|
|
2080
|
+
}
|
|
2081
|
+
return copy;
|
|
2082
|
+
});
|
|
2083
|
+
if (ws.readyState === 1) {
|
|
1993
2084
|
ws.send(JSON.stringify({
|
|
1994
2085
|
type: "projects_updated",
|
|
1995
|
-
projects:
|
|
1996
|
-
projectCount:
|
|
2086
|
+
projects: projectsWithUnread,
|
|
2087
|
+
projectCount: projectsWithUnread.length,
|
|
2088
|
+
removedProjects: getRemovedProjects(ws._clayUser ? ws._clayUser.id : null),
|
|
1997
2089
|
}));
|
|
1998
|
-
}
|
|
1999
|
-
});
|
|
2000
|
-
} else {
|
|
2001
|
-
broadcastAll({
|
|
2002
|
-
type: "projects_updated",
|
|
2003
|
-
projects: getProjects(),
|
|
2004
|
-
projectCount: projects.size,
|
|
2090
|
+
}
|
|
2005
2091
|
});
|
|
2006
|
-
}
|
|
2092
|
+
});
|
|
2007
2093
|
}, 200);
|
|
2008
2094
|
}
|
|
2009
2095
|
|
|
@@ -2099,6 +2185,7 @@ function createServer(opts) {
|
|
|
2099
2185
|
},
|
|
2100
2186
|
onPresenceChange: broadcastPresenceChange,
|
|
2101
2187
|
onProcessingChanged: broadcastProcessingChange,
|
|
2188
|
+
onSessionDone: function () { onSessionDone(slug); },
|
|
2102
2189
|
onAddProject: onAddProject,
|
|
2103
2190
|
onCreateProject: onCreateProject,
|
|
2104
2191
|
onCloneProject: onCloneProject,
|
|
@@ -2125,6 +2212,7 @@ function createServer(opts) {
|
|
|
2125
2212
|
onSetUpdateChannel: onSetUpdateChannel,
|
|
2126
2213
|
updateChannel: onGetDaemonConfig ? (onGetDaemonConfig().updateChannel || "stable") : "stable",
|
|
2127
2214
|
onShutdown: onShutdown,
|
|
2215
|
+
onRestart: onRestart,
|
|
2128
2216
|
onDmMessage: handleDmMessage,
|
|
2129
2217
|
});
|
|
2130
2218
|
projects.set(slug, ctx);
|
|
@@ -2217,6 +2305,41 @@ function createServer(opts) {
|
|
|
2217
2305
|
});
|
|
2218
2306
|
return;
|
|
2219
2307
|
}
|
|
2308
|
+
|
|
2309
|
+
if (msg.type === "dm_add_favorite") {
|
|
2310
|
+
if (!msg.targetUserId) return;
|
|
2311
|
+
users.removeDmHidden(userId, msg.targetUserId);
|
|
2312
|
+
var updatedFavorites = users.addDmFavorite(userId, msg.targetUserId);
|
|
2313
|
+
var allUsersList = users.getAllUsers().map(function (u) {
|
|
2314
|
+
var p = u.profile || {};
|
|
2315
|
+
return {
|
|
2316
|
+
id: u.id,
|
|
2317
|
+
displayName: p.name || u.displayName || u.username,
|
|
2318
|
+
username: u.username,
|
|
2319
|
+
role: u.role,
|
|
2320
|
+
avatarStyle: p.avatarStyle || "thumbs",
|
|
2321
|
+
avatarSeed: p.avatarSeed || u.username,
|
|
2322
|
+
avatarColor: p.avatarColor || "#7c3aed",
|
|
2323
|
+
};
|
|
2324
|
+
});
|
|
2325
|
+
ws.send(JSON.stringify({
|
|
2326
|
+
type: "dm_favorites_updated",
|
|
2327
|
+
dmFavorites: updatedFavorites,
|
|
2328
|
+
allUsers: allUsersList,
|
|
2329
|
+
}));
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
if (msg.type === "dm_remove_favorite") {
|
|
2334
|
+
if (!msg.targetUserId) return;
|
|
2335
|
+
users.addDmHidden(userId, msg.targetUserId);
|
|
2336
|
+
var updatedFavorites = users.removeDmFavorite(userId, msg.targetUserId);
|
|
2337
|
+
ws.send(JSON.stringify({
|
|
2338
|
+
type: "dm_favorites_updated",
|
|
2339
|
+
dmFavorites: updatedFavorites,
|
|
2340
|
+
}));
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2220
2343
|
}
|
|
2221
2344
|
|
|
2222
2345
|
function removeProject(slug) {
|
|
@@ -2296,7 +2419,7 @@ function createServer(opts) {
|
|
|
2296
2419
|
// Sends per-user filtered project lists + server-wide user list
|
|
2297
2420
|
var presenceTimer = null;
|
|
2298
2421
|
function broadcastPresenceChange() {
|
|
2299
|
-
if (presenceTimer)
|
|
2422
|
+
if (presenceTimer) clearTimeout(presenceTimer);
|
|
2300
2423
|
presenceTimer = setTimeout(function () {
|
|
2301
2424
|
presenceTimer = null;
|
|
2302
2425
|
if (!users.isMultiUser()) {
|
|
@@ -2304,6 +2427,7 @@ function createServer(opts) {
|
|
|
2304
2427
|
type: "projects_updated",
|
|
2305
2428
|
projects: getProjects(),
|
|
2306
2429
|
projectCount: projects.size,
|
|
2430
|
+
removedProjects: getRemovedProjects(),
|
|
2307
2431
|
});
|
|
2308
2432
|
return;
|
|
2309
2433
|
}
|
|
@@ -2340,12 +2464,27 @@ function createServer(opts) {
|
|
|
2340
2464
|
}
|
|
2341
2465
|
filteredProjects.push(status);
|
|
2342
2466
|
});
|
|
2467
|
+
// Per-user DM data
|
|
2468
|
+
var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
|
|
2469
|
+
var userDmHidden = userId ? users.getDmHidden(userId) : [];
|
|
2470
|
+
var userDmConversations = [];
|
|
2471
|
+
if (userId) {
|
|
2472
|
+
var dmList = dm.getDmList(userId);
|
|
2473
|
+
for (var di = 0; di < dmList.length; di++) {
|
|
2474
|
+
if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
|
|
2475
|
+
userDmConversations.push(dmList[di].otherUserId);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2343
2479
|
var msgStr = JSON.stringify({
|
|
2344
2480
|
type: "projects_updated",
|
|
2345
2481
|
projects: filteredProjects,
|
|
2346
2482
|
projectCount: projects.size,
|
|
2347
2483
|
serverUsers: serverUsers,
|
|
2348
2484
|
allUsers: allUsers,
|
|
2485
|
+
dmFavorites: userDmFavorites,
|
|
2486
|
+
dmConversations: userDmConversations,
|
|
2487
|
+
removedProjects: getRemovedProjects(userId),
|
|
2349
2488
|
});
|
|
2350
2489
|
sentUsers[key] = msgStr;
|
|
2351
2490
|
ws.send(msgStr);
|