clay-server 2.16.0 → 2.17.0-beta.10

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/lib/public/sw.js CHANGED
@@ -38,8 +38,11 @@ self.addEventListener("fetch", function (event) {
38
38
  // Only handle GET requests
39
39
  if (request.method !== "GET") return;
40
40
 
41
- // Skip WebSocket upgrade requests and API/data endpoints
41
+ // Skip cross-origin requests (external images, fonts, etc.)
42
42
  var url = new URL(request.url);
43
+ if (url.origin !== self.location.origin) return;
44
+
45
+ // Skip WebSocket upgrade requests and API/data endpoints
43
46
  if (url.pathname.indexOf("/ws") !== -1) return;
44
47
  if (url.pathname.indexOf("/api/") !== -1) return;
45
48
 
package/lib/server.js CHANGED
@@ -525,7 +525,7 @@ function createServer(opts) {
525
525
  var securityHeaders = {
526
526
  "X-Content-Type-Options": "nosniff",
527
527
  "X-Frame-Options": "DENY",
528
- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src * data: blob:; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh https://api.dicebear.com https://api.open-meteo.com; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
528
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src * data: blob:; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh https://api.dicebear.com https://api.open-meteo.com https://ipapi.co; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
529
529
  };
530
530
  if (tlsOptions) {
531
531
  securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
@@ -1017,6 +1017,19 @@ function createServer(opts) {
1017
1017
  return;
1018
1018
  }
1019
1019
 
1020
+ // PWA install guide (builtin cert mode, no CA step needed)
1021
+ if (fullUrl === "/pwa" && req.method === "GET") {
1022
+ var host = req.headers.host || "localhost";
1023
+ var hostname = host.split(":")[0];
1024
+ var protocol = tlsOptions ? "https" : "http";
1025
+ var pwaUrl = protocol + "://" + hostname + ":" + portNum;
1026
+ res.writeHead(200, {
1027
+ "Content-Type": "text/html; charset=utf-8",
1028
+ });
1029
+ res.end(setupPageHtml(pwaUrl, pwaUrl, false, true));
1030
+ return;
1031
+ }
1032
+
1020
1033
  // Global push endpoints (used by setup page)
1021
1034
  if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
1022
1035
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1086,7 +1099,7 @@ function createServer(opts) {
1086
1099
  res.end('{"error":"unauthorized"}');
1087
1100
  return;
1088
1101
  }
1089
- var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
1102
+ var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
1090
1103
  profile.username = mu.username;
1091
1104
  profile.userId = mu.id;
1092
1105
  profile.role = mu.role;
@@ -1094,7 +1107,7 @@ function createServer(opts) {
1094
1107
  res.end(JSON.stringify(profile));
1095
1108
  return;
1096
1109
  }
1097
- var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
1110
+ var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
1098
1111
  try {
1099
1112
  var raw = fs.readFileSync(profilePath, "utf8");
1100
1113
  var saved = JSON.parse(raw);
@@ -1103,7 +1116,18 @@ function createServer(opts) {
1103
1116
  if (saved.avatarColor) profile.avatarColor = saved.avatarColor;
1104
1117
  if (saved.avatarStyle) profile.avatarStyle = saved.avatarStyle;
1105
1118
  if (saved.avatarSeed) profile.avatarSeed = saved.avatarSeed;
1119
+ if (saved.avatarCustom) profile.avatarCustom = saved.avatarCustom;
1106
1120
  } catch (e) { /* file doesn't exist yet */ }
1121
+ // Check if custom avatar file exists
1122
+ try {
1123
+ var avatarFiles = fs.readdirSync(path.join(CONFIG_DIR, "avatars"));
1124
+ for (var afi = 0; afi < avatarFiles.length; afi++) {
1125
+ if (avatarFiles[afi].startsWith("default.")) {
1126
+ profile.avatarCustom = "/api/avatar/default?v=" + fs.statSync(path.join(CONFIG_DIR, "avatars", avatarFiles[afi])).mtimeMs;
1127
+ break;
1128
+ }
1129
+ }
1130
+ } catch (e) {}
1107
1131
  res.writeHead(200, { "Content-Type": "application/json" });
1108
1132
  res.end(JSON.stringify(profile));
1109
1133
  return;
@@ -1123,6 +1147,8 @@ function createServer(opts) {
1123
1147
  }
1124
1148
  if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
1125
1149
  if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
1150
+ if (typeof data.avatarCustom === "string") profile.avatarCustom = data.avatarCustom;
1151
+ if (data.avatarCustom === null || data.avatarCustom === "") profile.avatarCustom = undefined;
1126
1152
  if (users.isMultiUser()) {
1127
1153
  var mu = getMultiUserFromReq(req);
1128
1154
  if (!mu) {
@@ -1151,6 +1177,196 @@ function createServer(opts) {
1151
1177
  return;
1152
1178
  }
1153
1179
 
1180
+ // Upload custom avatar image
1181
+ if (req.method === "POST" && fullUrl === "/api/avatar") {
1182
+ var chunks = [];
1183
+ var totalSize = 0;
1184
+ var maxSize = 2 * 1024 * 1024; // 2MB
1185
+ req.on("data", function (chunk) {
1186
+ totalSize += chunk.length;
1187
+ if (totalSize <= maxSize) chunks.push(chunk);
1188
+ });
1189
+ req.on("end", function () {
1190
+ if (totalSize > maxSize) {
1191
+ res.writeHead(413, { "Content-Type": "application/json" });
1192
+ res.end('{"error":"File too large (max 2MB)"}');
1193
+ return;
1194
+ }
1195
+ var raw = Buffer.concat(chunks);
1196
+ // Detect content type from magic bytes
1197
+ var ct = null;
1198
+ if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
1199
+ else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
1200
+ else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
1201
+ else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
1202
+ if (!ct) {
1203
+ res.writeHead(400, { "Content-Type": "application/json" });
1204
+ res.end('{"error":"Unsupported image format"}');
1205
+ return;
1206
+ }
1207
+ var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
1208
+ var avatarDir = path.join(CONFIG_DIR, "avatars");
1209
+ fs.mkdirSync(avatarDir, { recursive: true });
1210
+
1211
+ var userId = "default";
1212
+ if (users.isMultiUser()) {
1213
+ var mu = getMultiUserFromReq(req);
1214
+ if (!mu) {
1215
+ res.writeHead(401, { "Content-Type": "application/json" });
1216
+ res.end('{"error":"unauthorized"}');
1217
+ return;
1218
+ }
1219
+ userId = mu.id;
1220
+ }
1221
+ var filename = userId + "." + ext;
1222
+ // Remove old avatar files for this user
1223
+ try {
1224
+ var existing = fs.readdirSync(avatarDir);
1225
+ for (var ei = 0; ei < existing.length; ei++) {
1226
+ if (existing[ei].startsWith(userId + ".")) {
1227
+ fs.unlinkSync(path.join(avatarDir, existing[ei]));
1228
+ }
1229
+ }
1230
+ } catch (e) {}
1231
+ fs.writeFileSync(path.join(avatarDir, filename), raw);
1232
+ res.writeHead(200, { "Content-Type": "application/json" });
1233
+ res.end(JSON.stringify({ ok: true, avatar: "/api/avatar/" + userId + "?v=" + Date.now() }));
1234
+ });
1235
+ return;
1236
+ }
1237
+
1238
+ // Serve custom avatar image
1239
+ if (req.method === "GET" && fullUrl.startsWith("/api/avatar/")) {
1240
+ var avatarUserId = fullUrl.split("/api/avatar/")[1].split("?")[0];
1241
+ var avatarDir = path.join(CONFIG_DIR, "avatars");
1242
+ try {
1243
+ var files = fs.readdirSync(avatarDir);
1244
+ var match = null;
1245
+ for (var fi = 0; fi < files.length; fi++) {
1246
+ if (files[fi].startsWith(avatarUserId + ".")) {
1247
+ match = files[fi];
1248
+ break;
1249
+ }
1250
+ }
1251
+ if (match) {
1252
+ var ext = match.split(".").pop();
1253
+ var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
1254
+ res.writeHead(200, {
1255
+ "Content-Type": ctMap[ext] || "application/octet-stream",
1256
+ "Cache-Control": "public, max-age=31536000, immutable",
1257
+ });
1258
+ res.end(fs.readFileSync(path.join(avatarDir, match)));
1259
+ return;
1260
+ }
1261
+ } catch (e) {}
1262
+ res.writeHead(404, { "Content-Type": "application/json" });
1263
+ res.end('{"error":"not found"}');
1264
+ return;
1265
+ }
1266
+
1267
+ // Upload custom avatar for a mate
1268
+ if (req.method === "POST" && fullUrl.startsWith("/api/mate-avatar/")) {
1269
+ var mateIdFromUrl = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
1270
+ if (!mateIdFromUrl) {
1271
+ res.writeHead(400, { "Content-Type": "application/json" });
1272
+ res.end('{"error":"Missing mate ID"}');
1273
+ return;
1274
+ }
1275
+ var chunks = [];
1276
+ var totalSize = 0;
1277
+ var maxSize = 2 * 1024 * 1024; // 2MB
1278
+ req.on("data", function (chunk) {
1279
+ totalSize += chunk.length;
1280
+ if (totalSize <= maxSize) chunks.push(chunk);
1281
+ });
1282
+ req.on("end", function () {
1283
+ if (totalSize > maxSize) {
1284
+ res.writeHead(413, { "Content-Type": "application/json" });
1285
+ res.end('{"error":"File too large (max 2MB)"}');
1286
+ return;
1287
+ }
1288
+ var raw = Buffer.concat(chunks);
1289
+ var ct = null;
1290
+ if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
1291
+ else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
1292
+ else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
1293
+ else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
1294
+ if (!ct) {
1295
+ res.writeHead(400, { "Content-Type": "application/json" });
1296
+ res.end('{"error":"Unsupported image format"}');
1297
+ return;
1298
+ }
1299
+ var userId = null;
1300
+ if (users.isMultiUser()) {
1301
+ var mu = getMultiUserFromReq(req);
1302
+ if (!mu) {
1303
+ res.writeHead(401, { "Content-Type": "application/json" });
1304
+ res.end('{"error":"unauthorized"}');
1305
+ return;
1306
+ }
1307
+ userId = mu.id;
1308
+ }
1309
+ var mateCtx = mates.buildMateCtx(userId);
1310
+ var mate = mates.getMate(mateCtx, mateIdFromUrl);
1311
+ if (!mate) {
1312
+ res.writeHead(404, { "Content-Type": "application/json" });
1313
+ res.end('{"error":"Mate not found"}');
1314
+ return;
1315
+ }
1316
+ var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
1317
+ var avatarDir = path.join(CONFIG_DIR, "mate-avatars");
1318
+ fs.mkdirSync(avatarDir, { recursive: true });
1319
+ var filename = mateIdFromUrl + "." + ext;
1320
+ // Remove old avatar files for this mate
1321
+ try {
1322
+ var existing = fs.readdirSync(avatarDir);
1323
+ for (var ei = 0; ei < existing.length; ei++) {
1324
+ if (existing[ei].startsWith(mateIdFromUrl + ".")) {
1325
+ fs.unlinkSync(path.join(avatarDir, existing[ei]));
1326
+ }
1327
+ }
1328
+ } catch (e) {}
1329
+ fs.writeFileSync(path.join(avatarDir, filename), raw);
1330
+ var avatarPath = "/api/mate-avatar/" + mateIdFromUrl + "?v=" + Date.now();
1331
+ // Update mate profile with custom avatar URL
1332
+ var profile = mate.profile || {};
1333
+ profile.avatarCustom = avatarPath;
1334
+ mates.updateMate(mateCtx, mateIdFromUrl, { profile: profile });
1335
+ res.writeHead(200, { "Content-Type": "application/json" });
1336
+ res.end(JSON.stringify({ ok: true, avatar: avatarPath }));
1337
+ });
1338
+ return;
1339
+ }
1340
+
1341
+ // Serve custom mate avatar image
1342
+ if (req.method === "GET" && fullUrl.startsWith("/api/mate-avatar/")) {
1343
+ var mateAvatarId = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
1344
+ var mateAvatarDir = path.join(CONFIG_DIR, "mate-avatars");
1345
+ try {
1346
+ var files = fs.readdirSync(mateAvatarDir);
1347
+ var match = null;
1348
+ for (var fi = 0; fi < files.length; fi++) {
1349
+ if (files[fi].startsWith(mateAvatarId + ".")) {
1350
+ match = files[fi];
1351
+ break;
1352
+ }
1353
+ }
1354
+ if (match) {
1355
+ var ext = match.split(".").pop();
1356
+ var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
1357
+ res.writeHead(200, {
1358
+ "Content-Type": ctMap[ext] || "application/octet-stream",
1359
+ "Cache-Control": "public, max-age=31536000, immutable",
1360
+ });
1361
+ res.end(fs.readFileSync(path.join(mateAvatarDir, match)));
1362
+ return;
1363
+ }
1364
+ } catch (e) {}
1365
+ res.writeHead(404, { "Content-Type": "application/json" });
1366
+ res.end('{"error":"not found"}');
1367
+ return;
1368
+ }
1369
+
1154
1370
  // Change own PIN (multi-user mode)
1155
1371
  if (req.method === "PUT" && fullUrl === "/api/user/pin") {
1156
1372
  if (!users.isMultiUser()) {
@@ -2613,6 +2829,7 @@ function createServer(opts) {
2613
2829
  avatarStyle: p.avatarStyle || "thumbs",
2614
2830
  avatarSeed: p.avatarSeed || otherUser.username,
2615
2831
  avatarColor: p.avatarColor || "#7c3aed",
2832
+ avatarCustom: p.avatarCustom || "",
2616
2833
  };
2617
2834
  }
2618
2835
  }
@@ -2675,6 +2892,7 @@ function createServer(opts) {
2675
2892
  avatarStyle: tp.avatarStyle || "thumbs",
2676
2893
  avatarSeed: tp.avatarSeed || targetUser.username,
2677
2894
  avatarColor: tp.avatarColor || "#7c3aed",
2895
+ avatarCustom: tp.avatarCustom || "",
2678
2896
  } : null,
2679
2897
  }));
2680
2898
  return;
@@ -2746,6 +2964,7 @@ function createServer(opts) {
2746
2964
  avatarStyle: p.avatarStyle || "thumbs",
2747
2965
  avatarSeed: p.avatarSeed || u.username,
2748
2966
  avatarColor: p.avatarColor || "#7c3aed",
2967
+ avatarCustom: p.avatarCustom || "",
2749
2968
  };
2750
2969
  });
2751
2970
  ws.send(JSON.stringify({
@@ -2913,6 +3132,7 @@ function createServer(opts) {
2913
3132
  username: u.username,
2914
3133
  avatarStyle: p.avatarStyle || "thumbs",
2915
3134
  avatarSeed: p.avatarSeed || u.username,
3135
+ avatarCustom: p.avatarCustom || "",
2916
3136
  });
2917
3137
  });
2918
3138
  });
@@ -2946,6 +3166,7 @@ function createServer(opts) {
2946
3166
  avatarStyle: p.avatarStyle || "thumbs",
2947
3167
  avatarSeed: p.avatarSeed || u.username,
2948
3168
  avatarColor: p.avatarColor || "#7c3aed",
3169
+ avatarCustom: p.avatarCustom || "",
2949
3170
  };
2950
3171
  });
2951
3172
  // Build per-user filtered lists, send individually
package/lib/sessions.js CHANGED
@@ -315,7 +315,7 @@ function createSessionManager(opts) {
315
315
  return 0;
316
316
  }
317
317
 
318
- function replayHistory(session, fromIndex, targetWs) {
318
+ function replayHistory(session, fromIndex, targetWs, transform) {
319
319
  var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
320
320
  var total = session.history.length;
321
321
  if (typeof fromIndex !== "number") {
@@ -329,7 +329,7 @@ function createSessionManager(opts) {
329
329
  _send({ type: "history_meta", total: total, from: fromIndex });
330
330
 
331
331
  for (var i = fromIndex; i < total; i++) {
332
- _send(session.history[i]);
332
+ _send(transform ? transform(session.history[i]) : session.history[i]);
333
333
  }
334
334
 
335
335
  // Find the last result message in the full history for accurate context data
@@ -351,7 +351,7 @@ function createSessionManager(opts) {
351
351
  _send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
352
352
  }
353
353
 
354
- function switchSession(localId, targetWs) {
354
+ function switchSession(localId, targetWs, transform) {
355
355
  var session = sessions.get(localId);
356
356
  if (!session) return;
357
357
 
@@ -374,7 +374,7 @@ function createSessionManager(opts) {
374
374
 
375
375
  _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null });
376
376
  broadcastSessionList();
377
- replayHistory(session, undefined, targetWs);
377
+ replayHistory(session, undefined, targetWs, transform);
378
378
 
379
379
  if (session.isProcessing) {
380
380
  _send({ type: "status", status: "processing" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.16.0",
3
+ "version": "2.17.0-beta.10",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",
@@ -1,10 +0,0 @@
1
- {
2
- "name": "Clay Light",
3
- "author": "Clay",
4
- "variant": "light",
5
- "base00": "F3EBE7", "base01": "EBE1DC", "base02": "D8CCC6", "base03": "A09590",
6
- "base04": "786D67", "base05": "504541", "base06": "332925", "base07": "1A1412",
7
- "base08": "C83520", "base09": "F74728", "base0A": "C08520", "base0B": "008F6B",
8
- "base0C": "1C8575", "base0D": "3560B0", "base0E": "8C4E8E", "base0F": "A57C45",
9
- "accent2": "2A26E5"
10
- }
@@ -1,10 +0,0 @@
1
- {
2
- "name": "Clay Dark",
3
- "author": "Clay",
4
- "variant": "dark",
5
- "base00": "1F1B1B", "base01": "2A2525", "base02": "352F2F", "base03": "7D7370",
6
- "base04": "A09590", "base05": "C2BAB4", "base06": "E5DED8", "base07": "FFFFFF",
7
- "base08": "F74728", "base09": "FE7150", "base0A": "E5A040", "base0B": "09E5A3",
8
- "base0C": "4EC9B0", "base0D": "6BA0E5", "base0E": "D085CC", "base0F": "D09558",
9
- "accent2": "5857FC"
10
- }