airloom 0.1.28 → 0.1.30
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/index.js +584 -111
- package/dist/viewer/assets/{browser-CNNk1Yyj.js → browser-C_JHU_N8.js} +1 -1
- package/dist/viewer/assets/{index-BrCBwFcp.js → index-D0JF99o3.js} +1 -1
- package/dist/viewer/assets/{index-BIIt6Ahm.js → index-DwLYzSfq.js} +17 -17
- package/dist/viewer/assets/index-SxG3HIBe.css +32 -0
- package/dist/viewer/index.html +26 -8
- package/package.json +1 -1
- package/dist/viewer/assets/index-DGkPtIMJ.css +0 -32
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ function decodeChannelMessage(data) {
|
|
|
38
38
|
function encodePairingData(data) {
|
|
39
39
|
return JSON.stringify(data);
|
|
40
40
|
}
|
|
41
|
+
var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
41
42
|
function randomCode(length) {
|
|
42
43
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
43
44
|
const limit = 256 - 256 % chars.length;
|
|
@@ -179,6 +180,7 @@ var Channel = class extends EventEmitter2 {
|
|
|
179
180
|
role;
|
|
180
181
|
streams = /* @__PURE__ */ new Map();
|
|
181
182
|
_ready = false;
|
|
183
|
+
_closed = false;
|
|
182
184
|
msgCounter = 0;
|
|
183
185
|
constructor(opts) {
|
|
184
186
|
super();
|
|
@@ -201,8 +203,12 @@ var Channel = class extends EventEmitter2 {
|
|
|
201
203
|
this._ready = false;
|
|
202
204
|
this.emit("peer_left");
|
|
203
205
|
});
|
|
204
|
-
this.adapter.onError((err) =>
|
|
205
|
-
|
|
206
|
+
this.adapter.onError((err) => {
|
|
207
|
+
if (!this._closed) this.emit("error", err);
|
|
208
|
+
});
|
|
209
|
+
this.adapter.onDisconnect(() => {
|
|
210
|
+
if (!this._closed) this.emit("disconnect");
|
|
211
|
+
});
|
|
206
212
|
}
|
|
207
213
|
handlePayload(base64Payload) {
|
|
208
214
|
try {
|
|
@@ -296,6 +302,7 @@ var Channel = class extends EventEmitter2 {
|
|
|
296
302
|
});
|
|
297
303
|
}
|
|
298
304
|
close() {
|
|
305
|
+
this._closed = true;
|
|
299
306
|
for (const stream of this.streams.values()) stream._end();
|
|
300
307
|
this.streams.clear();
|
|
301
308
|
this.adapter.close();
|
|
@@ -553,9 +560,15 @@ var AblyAdapter = class {
|
|
|
553
560
|
}
|
|
554
561
|
});
|
|
555
562
|
this.channel.presence.subscribe("leave", (member) => {
|
|
556
|
-
if (member.clientId
|
|
563
|
+
if (member.clientId === this.clientId) return;
|
|
564
|
+
this.channel.presence.get().then((members2) => {
|
|
565
|
+
const hasPeer2 = members2.some((m) => m.clientId !== this.clientId);
|
|
566
|
+
if (!hasPeer2) {
|
|
567
|
+
this.peerLeftHandlers.forEach((h) => h());
|
|
568
|
+
}
|
|
569
|
+
}).catch(() => {
|
|
557
570
|
this.peerLeftHandlers.forEach((h) => h());
|
|
558
|
-
}
|
|
571
|
+
});
|
|
559
572
|
});
|
|
560
573
|
await this.channel.presence.enter({ role });
|
|
561
574
|
const members = await this.channel.presence.get();
|
|
@@ -566,7 +579,10 @@ var AblyAdapter = class {
|
|
|
566
579
|
}
|
|
567
580
|
send(payload) {
|
|
568
581
|
if (!this.channel || !this._connected) return;
|
|
569
|
-
this.channel.publish("forward", payload)
|
|
582
|
+
this.channel.publish("forward", payload).catch((err) => {
|
|
583
|
+
console.error("[ably] publish error:", err.message);
|
|
584
|
+
this.errorHandlers.forEach((h) => h(err));
|
|
585
|
+
});
|
|
570
586
|
}
|
|
571
587
|
onMessage(handler) {
|
|
572
588
|
this.messageHandlers.push(handler);
|
|
@@ -584,6 +600,11 @@ var AblyAdapter = class {
|
|
|
584
600
|
this.disconnectHandlers.push(handler);
|
|
585
601
|
}
|
|
586
602
|
close() {
|
|
603
|
+
this.messageHandlers = [];
|
|
604
|
+
this.peerJoinedHandlers = [];
|
|
605
|
+
this.peerLeftHandlers = [];
|
|
606
|
+
this.errorHandlers = [];
|
|
607
|
+
this.disconnectHandlers = [];
|
|
587
608
|
this.channel?.presence.leave().catch(() => {
|
|
588
609
|
});
|
|
589
610
|
this.channel?.detach().catch(() => {
|
|
@@ -744,6 +765,24 @@ var OpenAIAdapter = class {
|
|
|
744
765
|
import { spawn } from "node:child_process";
|
|
745
766
|
import { existsSync } from "node:fs";
|
|
746
767
|
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
768
|
+
|
|
769
|
+
// src/log.ts
|
|
770
|
+
function ts() {
|
|
771
|
+
const d = /* @__PURE__ */ new Date();
|
|
772
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
773
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
774
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
775
|
+
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
|
776
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
777
|
+
}
|
|
778
|
+
function log(...args) {
|
|
779
|
+
console.log(ts(), ...args);
|
|
780
|
+
}
|
|
781
|
+
function logError(...args) {
|
|
782
|
+
console.error(ts(), ...args);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/adapters/cli.ts
|
|
747
786
|
var CLI_PRESETS = [
|
|
748
787
|
{
|
|
749
788
|
id: "devin",
|
|
@@ -884,7 +923,7 @@ var CLIAdapter = class {
|
|
|
884
923
|
if (this.pty) return this.pty;
|
|
885
924
|
const nodePty = await import("node-pty");
|
|
886
925
|
const executable = resolveExecutable(this.command) ?? this.command;
|
|
887
|
-
|
|
926
|
+
log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
|
|
888
927
|
const pty = nodePty.spawn(executable, this.args, {
|
|
889
928
|
name: "xterm-256color",
|
|
890
929
|
cols: 120,
|
|
@@ -894,7 +933,7 @@ var CLIAdapter = class {
|
|
|
894
933
|
});
|
|
895
934
|
pty.onData((data) => this.onData(data));
|
|
896
935
|
pty.onExit(({ exitCode }) => {
|
|
897
|
-
|
|
936
|
+
log(`[cli-repl] PTY exited (code ${exitCode})`);
|
|
898
937
|
this.pty = null;
|
|
899
938
|
this.ptyState = "idle";
|
|
900
939
|
this.finishResponse();
|
|
@@ -1017,7 +1056,7 @@ function saveConfig(config) {
|
|
|
1017
1056
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1018
1057
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1019
1058
|
} catch (err) {
|
|
1020
|
-
|
|
1059
|
+
logError("[config] Failed to save:", err.message);
|
|
1021
1060
|
}
|
|
1022
1061
|
}
|
|
1023
1062
|
function getConfigPath() {
|
|
@@ -1038,7 +1077,7 @@ function fixSpawnHelperPermissions() {
|
|
|
1038
1077
|
const mode = statSync(helperPath).mode;
|
|
1039
1078
|
if (!(mode & 73)) {
|
|
1040
1079
|
chmodSync(helperPath, mode | 493);
|
|
1041
|
-
|
|
1080
|
+
log(`[host] Fixed spawn-helper permissions: ${helperPath}`);
|
|
1042
1081
|
}
|
|
1043
1082
|
} catch {
|
|
1044
1083
|
}
|
|
@@ -1169,25 +1208,29 @@ var TerminalSession = class {
|
|
|
1169
1208
|
cols = 120;
|
|
1170
1209
|
rows = 36;
|
|
1171
1210
|
outputBuffer = "";
|
|
1211
|
+
/** True after the first viewer has ever attached. The pre-viewer output
|
|
1212
|
+
* buffer contains PTY startup junk (zsh PROMPT_SP "%", resize-triggered
|
|
1213
|
+
* prompt redraws) that shouldn't be replayed. */
|
|
1214
|
+
hasHadViewer = false;
|
|
1172
1215
|
start() {
|
|
1173
1216
|
const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
|
|
1174
1217
|
const file = resolveExecutable2(command.file) ?? command.file;
|
|
1175
1218
|
const cwd = process.cwd();
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env };
|
|
1219
|
+
log(`[host] PTY spawn: ${file} ${command.args.join(" ")} (${this.cols}x${this.rows}) node=${process.version}`);
|
|
1220
|
+
const env2 = { ...process.env, TERM: "xterm-256color" };
|
|
1221
|
+
const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env: env2 };
|
|
1179
1222
|
try {
|
|
1180
1223
|
this.pty = spawn2(file, command.args, spawnOpts);
|
|
1181
1224
|
} catch (err) {
|
|
1182
1225
|
const e = err;
|
|
1183
|
-
|
|
1226
|
+
logError(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
|
|
1184
1227
|
if (file !== "/bin/sh") {
|
|
1185
|
-
|
|
1228
|
+
logError("[host] Retrying with /bin/sh...");
|
|
1186
1229
|
try {
|
|
1187
1230
|
this.pty = spawn2("/bin/sh", [], spawnOpts);
|
|
1188
|
-
|
|
1231
|
+
log("[host] PTY fallback to /bin/sh succeeded");
|
|
1189
1232
|
} catch (err2) {
|
|
1190
|
-
|
|
1233
|
+
logError("[host] PTY fallback also failed:", err2.message);
|
|
1191
1234
|
return;
|
|
1192
1235
|
}
|
|
1193
1236
|
} else {
|
|
@@ -1195,7 +1238,6 @@ var TerminalSession = class {
|
|
|
1195
1238
|
}
|
|
1196
1239
|
}
|
|
1197
1240
|
this.pty.onData((data) => {
|
|
1198
|
-
process.stdout.write(data);
|
|
1199
1241
|
this.outputBuffer += data;
|
|
1200
1242
|
if (this.outputBuffer.length > MAX_BUFFER_BYTES) {
|
|
1201
1243
|
this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - MAX_BUFFER_BYTES);
|
|
@@ -1231,19 +1273,33 @@ var TerminalSession = class {
|
|
|
1231
1273
|
attach(message) {
|
|
1232
1274
|
this.cols = Math.max(20, Math.floor(message.cols || this.cols));
|
|
1233
1275
|
this.rows = Math.max(5, Math.floor(message.rows || this.rows));
|
|
1234
|
-
this.pty?.resize(this.cols, this.rows);
|
|
1235
1276
|
this.detachStream();
|
|
1236
1277
|
const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
|
|
1237
1278
|
this.stream = this.channel.createStream(meta);
|
|
1238
1279
|
this.batcher = new AdaptiveOutputBatcher((data) => {
|
|
1239
1280
|
this.stream?.write(data);
|
|
1240
1281
|
});
|
|
1241
|
-
if (this.outputBuffer) {
|
|
1242
|
-
this.stream.write(this.outputBuffer);
|
|
1243
|
-
}
|
|
1244
1282
|
if (!this.pty) {
|
|
1245
1283
|
this.start();
|
|
1246
1284
|
}
|
|
1285
|
+
const isFirstViewer = !this.hasHadViewer;
|
|
1286
|
+
if (!isFirstViewer && this.outputBuffer) {
|
|
1287
|
+
const REPLAY_CAP = 60 * 1024;
|
|
1288
|
+
const replay = this.outputBuffer.length <= REPLAY_CAP ? this.outputBuffer : this.outputBuffer.slice(this.outputBuffer.length - REPLAY_CAP);
|
|
1289
|
+
this.stream.write(replay);
|
|
1290
|
+
}
|
|
1291
|
+
this.outputBuffer = "";
|
|
1292
|
+
this.hasHadViewer = true;
|
|
1293
|
+
if (this.pty) {
|
|
1294
|
+
this.pty.resize(this.cols, this.rows);
|
|
1295
|
+
}
|
|
1296
|
+
if (isFirstViewer) {
|
|
1297
|
+
setTimeout(() => {
|
|
1298
|
+
if (this.pty && this.batcher) {
|
|
1299
|
+
this.pty.write("\f");
|
|
1300
|
+
}
|
|
1301
|
+
}, 250);
|
|
1302
|
+
}
|
|
1247
1303
|
}
|
|
1248
1304
|
/** End the current stream without killing the PTY (called on peer disconnect). */
|
|
1249
1305
|
detachStream() {
|
|
@@ -1288,6 +1344,73 @@ function isTerminalMessage(data) {
|
|
|
1288
1344
|
return type === "terminal_open" || type === "terminal_input" || type === "terminal_resize" || type === "terminal_close" || type === "terminal_exit";
|
|
1289
1345
|
}
|
|
1290
1346
|
|
|
1347
|
+
// src/security.ts
|
|
1348
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1349
|
+
var CONTROL_COOKIE_NAME = "airloom_control";
|
|
1350
|
+
var FixedWindowRateLimiter = class {
|
|
1351
|
+
entries = /* @__PURE__ */ new Map();
|
|
1352
|
+
allow(key, max, windowMs) {
|
|
1353
|
+
const now = Date.now();
|
|
1354
|
+
const entry = this.entries.get(key);
|
|
1355
|
+
if (!entry || entry.resetAt <= now) {
|
|
1356
|
+
this.entries.set(key, { count: 1, resetAt: now + windowMs });
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
if (entry.count >= max) return false;
|
|
1360
|
+
entry.count += 1;
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
function createControlToken() {
|
|
1365
|
+
return randomBytes(24).toString("base64url");
|
|
1366
|
+
}
|
|
1367
|
+
function encodeControlUrl(baseUrl, token) {
|
|
1368
|
+
const url = new URL(baseUrl);
|
|
1369
|
+
url.searchParams.set("t", token);
|
|
1370
|
+
return url.toString();
|
|
1371
|
+
}
|
|
1372
|
+
function parseCookies(header) {
|
|
1373
|
+
const result = {};
|
|
1374
|
+
if (!header) return result;
|
|
1375
|
+
for (const part of header.split(";")) {
|
|
1376
|
+
const [rawKey, ...rawValue] = part.trim().split("=");
|
|
1377
|
+
if (!rawKey) continue;
|
|
1378
|
+
result[rawKey] = decodeURIComponent(rawValue.join("="));
|
|
1379
|
+
}
|
|
1380
|
+
return result;
|
|
1381
|
+
}
|
|
1382
|
+
function safeEqual(candidate, expected) {
|
|
1383
|
+
const left = Buffer.from(candidate);
|
|
1384
|
+
const right = Buffer.from(expected);
|
|
1385
|
+
if (left.length !== right.length) return false;
|
|
1386
|
+
return timingSafeEqual(left, right);
|
|
1387
|
+
}
|
|
1388
|
+
function readQueryToken(req) {
|
|
1389
|
+
if (req.query && typeof req.query.t === "string") return req.query.t;
|
|
1390
|
+
if (!req.url || !req.headers.host) return null;
|
|
1391
|
+
try {
|
|
1392
|
+
return new URL(req.url, `http://${req.headers.host}`).searchParams.get("t");
|
|
1393
|
+
} catch {
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
function readControlToken(req) {
|
|
1398
|
+
const headerToken = req.headers["x-airloom-control"];
|
|
1399
|
+
if (typeof headerToken === "string") return headerToken;
|
|
1400
|
+
const queryToken = readQueryToken(req);
|
|
1401
|
+
if (queryToken) return queryToken;
|
|
1402
|
+
return parseCookies(req.headers.cookie)[CONTROL_COOKIE_NAME] ?? null;
|
|
1403
|
+
}
|
|
1404
|
+
function hasValidControlToken(req, expected) {
|
|
1405
|
+
const token = readControlToken(req);
|
|
1406
|
+
return typeof token === "string" && safeEqual(token, expected);
|
|
1407
|
+
}
|
|
1408
|
+
function hasAllowedOrigin(headers, host) {
|
|
1409
|
+
const origin = headers.origin;
|
|
1410
|
+
if (!origin) return true;
|
|
1411
|
+
return origin === `http://${host}` || origin === `https://${host}`;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1291
1414
|
// src/server.ts
|
|
1292
1415
|
var MAX_MESSAGES = 200;
|
|
1293
1416
|
function trimMessages(messages) {
|
|
@@ -1295,21 +1418,95 @@ function trimMessages(messages) {
|
|
|
1295
1418
|
}
|
|
1296
1419
|
var aiLock = Promise.resolve();
|
|
1297
1420
|
function enqueueAIResponse(channel, adapter, state, broadcast) {
|
|
1298
|
-
aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) =>
|
|
1421
|
+
aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) => logError("[host] AI response error:", err));
|
|
1299
1422
|
}
|
|
1300
1423
|
function createHostServer(opts) {
|
|
1301
1424
|
const app = express();
|
|
1302
1425
|
const server = createServer(app);
|
|
1303
|
-
const wss = new WebSocketServer({
|
|
1426
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1304
1427
|
const uiClients = /* @__PURE__ */ new Set();
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1428
|
+
const rateLimiter = new FixedWindowRateLimiter();
|
|
1429
|
+
function requestKey(req) {
|
|
1430
|
+
return req.ip || req.socket.remoteAddress || "unknown";
|
|
1431
|
+
}
|
|
1432
|
+
function setControlCookie(req, res) {
|
|
1433
|
+
const parts = [
|
|
1434
|
+
`${CONTROL_COOKIE_NAME}=${encodeURIComponent(opts.controlToken)}`,
|
|
1435
|
+
"HttpOnly",
|
|
1436
|
+
"SameSite=Strict",
|
|
1437
|
+
"Path=/"
|
|
1438
|
+
];
|
|
1439
|
+
if (req.secure) parts.push("Secure");
|
|
1440
|
+
res.setHeader("Set-Cookie", parts.join("; "));
|
|
1441
|
+
}
|
|
1442
|
+
function requireSameOrigin(req, res) {
|
|
1443
|
+
const host = req.get("host");
|
|
1444
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1445
|
+
res.status(403).json({ error: "Forbidden" });
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
return true;
|
|
1449
|
+
}
|
|
1450
|
+
function requireControlAuth(req, res) {
|
|
1451
|
+
if (!requireSameOrigin(req, res)) return false;
|
|
1452
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1453
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
if (readControlToken(req) === opts.controlToken) setControlCookie(req, res);
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
function requireRateLimit(req, res, bucket, max, windowMs) {
|
|
1460
|
+
if (rateLimiter.allow(`${bucket}:${requestKey(req)}`, max, windowMs)) return true;
|
|
1461
|
+
res.status(429).json({ error: "Rate limited" });
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
function rejectUpgrade(socket, statusCode, message) {
|
|
1465
|
+
socket.write(`HTTP/1.1 ${statusCode} ${message}\r
|
|
1466
|
+
Connection: close\r
|
|
1467
|
+
Content-Type: text/plain; charset=utf-8\r
|
|
1468
|
+
Content-Length: ${Buffer.byteLength(message)}\r
|
|
1469
|
+
\r
|
|
1470
|
+
${message}`);
|
|
1471
|
+
socket.destroy();
|
|
1472
|
+
}
|
|
1473
|
+
app.disable("x-powered-by");
|
|
1474
|
+
app.use(express.json({ limit: "16kb" }));
|
|
1475
|
+
app.use((_req, res, next) => {
|
|
1476
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1477
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1478
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1479
|
+
next();
|
|
1480
|
+
});
|
|
1481
|
+
app.get("/healthz", (_req, res) => {
|
|
1482
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1483
|
+
res.json({ ok: true, connected: opts.state.connected, transport: opts.state.transport ?? "ws" });
|
|
1308
1484
|
});
|
|
1309
1485
|
if (opts.viewerDir && existsSync3(opts.viewerDir)) {
|
|
1310
1486
|
app.use("/viewer", express.static(opts.viewerDir));
|
|
1311
1487
|
}
|
|
1312
|
-
app.get("/
|
|
1488
|
+
app.get("/", (req, res) => {
|
|
1489
|
+
if (!requireRateLimit(req, res, "root", 20, 6e4)) return;
|
|
1490
|
+
const host = req.get("host");
|
|
1491
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1492
|
+
res.status(403).type("text/plain").send("Forbidden");
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1496
|
+
res.status(401).type("text/plain").send("Unauthorized. Open the tokenized Airloom URL printed by the host process.");
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
setControlCookie(req, res);
|
|
1500
|
+
if (typeof req.query.t === "string") {
|
|
1501
|
+
res.redirect("/");
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1505
|
+
res.type("html").send(HOST_HTML);
|
|
1506
|
+
});
|
|
1507
|
+
app.get("/api/status", (req, res) => {
|
|
1508
|
+
if (!requireRateLimit(req, res, "status", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1509
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1313
1510
|
res.json({
|
|
1314
1511
|
connected: opts.state.connected,
|
|
1315
1512
|
pairingCode: opts.state.pairingCode,
|
|
@@ -1320,11 +1517,15 @@ function createHostServer(opts) {
|
|
|
1320
1517
|
messages: opts.state.messages
|
|
1321
1518
|
});
|
|
1322
1519
|
});
|
|
1323
|
-
app.get("/api/cli-presets", (
|
|
1520
|
+
app.get("/api/cli-presets", (req, res) => {
|
|
1521
|
+
if (!requireRateLimit(req, res, "cli-presets", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1522
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1324
1523
|
res.json(CLI_PRESETS);
|
|
1325
1524
|
});
|
|
1326
|
-
app.get("/api/config", (
|
|
1525
|
+
app.get("/api/config", (req, res) => {
|
|
1526
|
+
if (!requireRateLimit(req, res, "config-get", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1327
1527
|
const saved = loadConfig();
|
|
1528
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1328
1529
|
res.json({
|
|
1329
1530
|
saved,
|
|
1330
1531
|
envKeys: {
|
|
@@ -1333,32 +1534,52 @@ function createHostServer(opts) {
|
|
|
1333
1534
|
}
|
|
1334
1535
|
});
|
|
1335
1536
|
});
|
|
1537
|
+
const allowedPresetIds = /* @__PURE__ */ new Set(["shell", ...CLI_PRESETS.map((preset) => preset.id)]);
|
|
1538
|
+
function readString(value, maxLength) {
|
|
1539
|
+
if (typeof value !== "string") return void 0;
|
|
1540
|
+
const trimmed = value.trim();
|
|
1541
|
+
if (!trimmed) return void 0;
|
|
1542
|
+
return trimmed.length <= maxLength ? trimmed : void 0;
|
|
1543
|
+
}
|
|
1336
1544
|
app.post("/api/configure", (req, res) => {
|
|
1545
|
+
if (!requireRateLimit(req, res, "configure", 10, 6e4) || !requireControlAuth(req, res)) return;
|
|
1546
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1337
1547
|
const { type, apiKey, model, command, preset } = req.body;
|
|
1548
|
+
if (type !== "anthropic" && type !== "openai" && type !== "cli") {
|
|
1549
|
+
res.status(400).json({ error: "Unknown adapter type" });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const normalizedApiKey = readString(apiKey, 512);
|
|
1553
|
+
const normalizedModel = readString(model, 200);
|
|
1554
|
+
const normalizedCommand = readString(command, 512);
|
|
1555
|
+
const selectedPreset = typeof preset === "string" ? preset : "shell";
|
|
1556
|
+
if (!allowedPresetIds.has(selectedPreset)) {
|
|
1557
|
+
res.status(400).json({ error: "Unknown preset" });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1338
1560
|
try {
|
|
1339
1561
|
switch (type) {
|
|
1340
1562
|
case "anthropic": {
|
|
1341
|
-
const key =
|
|
1563
|
+
const key = normalizedApiKey || process.env.ANTHROPIC_API_KEY;
|
|
1342
1564
|
if (!key) {
|
|
1343
1565
|
res.status(400).json({ error: "API key required (or set ANTHROPIC_API_KEY env var)" });
|
|
1344
1566
|
return;
|
|
1345
1567
|
}
|
|
1346
|
-
opts.state.adapter = new AnthropicAdapter({ apiKey: key, model });
|
|
1568
|
+
opts.state.adapter = new AnthropicAdapter({ apiKey: key, model: normalizedModel });
|
|
1347
1569
|
break;
|
|
1348
1570
|
}
|
|
1349
1571
|
case "openai": {
|
|
1350
|
-
const key =
|
|
1572
|
+
const key = normalizedApiKey || process.env.OPENAI_API_KEY;
|
|
1351
1573
|
if (!key) {
|
|
1352
1574
|
res.status(400).json({ error: "API key required (or set OPENAI_API_KEY env var)" });
|
|
1353
1575
|
return;
|
|
1354
1576
|
}
|
|
1355
|
-
opts.state.adapter = new OpenAIAdapter({ apiKey: key, model });
|
|
1577
|
+
opts.state.adapter = new OpenAIAdapter({ apiKey: key, model: normalizedModel });
|
|
1356
1578
|
break;
|
|
1357
1579
|
}
|
|
1358
1580
|
case "cli": {
|
|
1359
|
-
const selectedPreset = typeof preset === "string" ? preset : "shell";
|
|
1360
1581
|
const presetInfo = CLI_PRESETS.find((p) => p.id === selectedPreset);
|
|
1361
|
-
const cmd = selectedPreset === "shell" ? void 0 :
|
|
1582
|
+
const cmd = selectedPreset === "shell" ? void 0 : normalizedCommand ?? presetInfo?.command;
|
|
1362
1583
|
opts.state.terminalLaunchCommand = cmd;
|
|
1363
1584
|
opts.state.terminalLaunch = getTerminalLaunchDisplay(cmd);
|
|
1364
1585
|
opts.state.adapter = null;
|
|
@@ -1368,15 +1589,12 @@ function createHostServer(opts) {
|
|
|
1368
1589
|
res.json({ ok: true, terminalLaunch: opts.state.terminalLaunch });
|
|
1369
1590
|
return;
|
|
1370
1591
|
}
|
|
1371
|
-
default:
|
|
1372
|
-
res.status(400).json({ error: "Unknown adapter type" });
|
|
1373
|
-
return;
|
|
1374
1592
|
}
|
|
1375
1593
|
const cfg = { type };
|
|
1376
|
-
if (
|
|
1594
|
+
if (normalizedModel) cfg.model = normalizedModel;
|
|
1377
1595
|
if (type === "cli") {
|
|
1378
|
-
cfg.command =
|
|
1379
|
-
cfg.preset =
|
|
1596
|
+
cfg.command = normalizedCommand;
|
|
1597
|
+
cfg.preset = selectedPreset;
|
|
1380
1598
|
}
|
|
1381
1599
|
saveConfig(cfg);
|
|
1382
1600
|
broadcast({ type: "configured", adapter: { name: opts.state.adapter?.name ?? "none", model: opts.state.adapter?.model ?? "" } });
|
|
@@ -1387,7 +1605,9 @@ function createHostServer(opts) {
|
|
|
1387
1605
|
}
|
|
1388
1606
|
});
|
|
1389
1607
|
app.post("/api/send", async (req, res) => {
|
|
1390
|
-
|
|
1608
|
+
if (!requireRateLimit(req, res, "send", 30, 6e4) || !requireControlAuth(req, res)) return;
|
|
1609
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1610
|
+
const content = readString(req.body?.content, 4e3);
|
|
1391
1611
|
if (!content) {
|
|
1392
1612
|
res.status(400).json({ error: "No content" });
|
|
1393
1613
|
return;
|
|
@@ -1401,30 +1621,73 @@ function createHostServer(opts) {
|
|
|
1401
1621
|
res.json({ ok: true });
|
|
1402
1622
|
});
|
|
1403
1623
|
app.get("/api/pair", (req, res) => {
|
|
1624
|
+
if (!requireRateLimit(req, res, "pair", 12, 6e4)) return;
|
|
1625
|
+
const host = req.get("host");
|
|
1626
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1627
|
+
res.status(403).json({ error: "Forbidden" });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1404
1630
|
const { session } = req.query;
|
|
1405
|
-
if (!session || session !== opts.state.
|
|
1631
|
+
if (!session || !/^[0-9a-f]{32}$/i.test(session) || session !== opts.state.pairingSessionToken) {
|
|
1406
1632
|
res.status(401).json({ error: "Invalid session" });
|
|
1407
1633
|
return;
|
|
1408
1634
|
}
|
|
1635
|
+
if (!opts.state.relaySessionToken) {
|
|
1636
|
+
res.status(503).json({ error: "Pairing unavailable" });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1409
1640
|
res.json({
|
|
1641
|
+
session: opts.state.relaySessionToken,
|
|
1410
1642
|
token: opts.state.ablyToken,
|
|
1643
|
+
tokenExpiresAt: opts.state.ablyTokenExpiresAt,
|
|
1411
1644
|
transport: opts.state.transport ?? "ws",
|
|
1412
1645
|
relay: opts.state.relayUrl
|
|
1413
1646
|
});
|
|
1414
1647
|
});
|
|
1648
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1649
|
+
const host = req.headers.host;
|
|
1650
|
+
if (!host) {
|
|
1651
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
let url;
|
|
1655
|
+
try {
|
|
1656
|
+
url = new URL(req.url ?? "/", `http://${host}`);
|
|
1657
|
+
} catch {
|
|
1658
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (url.pathname !== "/ws") {
|
|
1662
|
+
socket.destroy();
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
if (!hasAllowedOrigin(req.headers, host)) {
|
|
1666
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1670
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1674
|
+
wss.emit("connection", ws, req);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1415
1677
|
wss.on("connection", (ws) => {
|
|
1416
1678
|
uiClients.add(ws);
|
|
1417
1679
|
ws.on("close", () => uiClients.delete(ws));
|
|
1418
1680
|
ws.on("message", (data) => {
|
|
1419
1681
|
try {
|
|
1682
|
+
if (data.toString().length > 16384) return;
|
|
1420
1683
|
const message = JSON.parse(data.toString());
|
|
1421
|
-
if (message.type === "terminal_input" && typeof message.data === "string") {
|
|
1684
|
+
if (message.type === "terminal_input" && typeof message.data === "string" && message.data.length <= 8192) {
|
|
1422
1685
|
opts.state.terminal?.writeRawInput(message.data);
|
|
1423
|
-
} else if (message.type === "terminal_resize") {
|
|
1686
|
+
} else if (message.type === "terminal_resize" && Number.isInteger(message.cols) && Number.isInteger(message.rows) && message.cols > 0 && message.rows > 0) {
|
|
1424
1687
|
opts.state.terminal?.handleMessage(message);
|
|
1425
1688
|
}
|
|
1426
1689
|
} catch (err) {
|
|
1427
|
-
|
|
1690
|
+
logError("[host] Invalid WebSocket message:", err);
|
|
1428
1691
|
}
|
|
1429
1692
|
});
|
|
1430
1693
|
});
|
|
@@ -1434,16 +1697,35 @@ function createHostServer(opts) {
|
|
|
1434
1697
|
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
|
1435
1698
|
}
|
|
1436
1699
|
}
|
|
1437
|
-
return new Promise((resolve4) => {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1700
|
+
return new Promise((resolve4, reject) => {
|
|
1701
|
+
const MAX_PORT_ATTEMPTS = 20;
|
|
1702
|
+
let attempt = 0;
|
|
1703
|
+
let port = opts.port;
|
|
1704
|
+
function tryListen() {
|
|
1705
|
+
server.once("error", onError);
|
|
1706
|
+
server.listen(port, opts.bind, () => {
|
|
1707
|
+
server.removeListener("error", onError);
|
|
1708
|
+
const addr = server.address();
|
|
1709
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1710
|
+
resolve4({ server, broadcast, port: actualPort });
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
function onError(err) {
|
|
1714
|
+
server.removeListener("error", onError);
|
|
1715
|
+
if (err.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS) {
|
|
1716
|
+
attempt++;
|
|
1717
|
+
port++;
|
|
1718
|
+
if (port > 65535) {
|
|
1719
|
+
reject(err);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
log(`[host] Port ${port - 1} in use, trying ${port}...`);
|
|
1723
|
+
tryListen();
|
|
1724
|
+
} else {
|
|
1725
|
+
reject(err);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
tryListen();
|
|
1447
1729
|
});
|
|
1448
1730
|
}
|
|
1449
1731
|
async function handleAIResponse(channel, adapter, state, broadcast) {
|
|
@@ -1493,14 +1775,19 @@ var HOST_HTML = `<!DOCTYPE html>
|
|
|
1493
1775
|
<title>Airloom - Host</title>
|
|
1494
1776
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
|
|
1495
1777
|
<style>
|
|
1496
|
-
:root{color-scheme:
|
|
1497
|
-
|
|
1778
|
+
:root{color-scheme:dark;--bg:#0a0a0a;--surface:#1a1a1a;--border:#2a2a2a;--text:#e0e0e0;--text-muted:#888;--accent:#7c8aff;--accent-hover:#6b79ee;--input-bg:#111;--input-border:#333;--term-bg:#05070c;--tool-bg:#333;--tool-hover:#444;--msg-user:#2a3a6a;--msg-asst:#1e1e1e}
|
|
1779
|
+
[data-theme="light"]{color-scheme:light;--bg:#f5f5f7;--surface:#fff;--border:#d1d1d6;--text:#1c1c1e;--text-muted:#6e6e73;--accent:#5856d6;--accent-hover:#4a48c4;--input-bg:#fff;--input-border:#d1d1d6;--term-bg:#fff;--tool-bg:#d1d1d6;--tool-hover:#c0c0c5;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}
|
|
1780
|
+
@media(prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f5f7;--surface:#fff;--border:#d1d1d6;--text:#1c1c1e;--text-muted:#6e6e73;--accent:#5856d6;--accent-hover:#4a48c4;--input-bg:#fff;--input-border:#d1d1d6;--term-bg:#fff;--tool-bg:#d1d1d6;--tool-hover:#c0c0c5;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}}
|
|
1498
1781
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
1499
1782
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
1500
1783
|
.container{max-width:800px;margin:0 auto;padding:20px}
|
|
1501
1784
|
.page-header{display:flex;align-items:center;gap:12px;margin-bottom:20px}
|
|
1502
1785
|
.page-header svg{width:36px;height:36px;flex-shrink:0}
|
|
1503
|
-
.page-header h1{font-size:1.5rem;color:var(--accent)}
|
|
1786
|
+
.page-header h1{font-size:1.5rem;color:var(--accent);flex:1}
|
|
1787
|
+
.theme-switch{display:flex;gap:2px;background:var(--border);border-radius:8px;padding:2px}
|
|
1788
|
+
.theme-btn{padding:5px 10px;font-size:.8rem;background:transparent;border:none;border-radius:6px;color:var(--text-muted);cursor:pointer;font-weight:500;line-height:1}
|
|
1789
|
+
.theme-btn:hover{color:var(--text)}
|
|
1790
|
+
.theme-btn.active{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.15)}
|
|
1504
1791
|
h2{font-size:1.1rem;margin-bottom:12px;color:var(--text-muted)}
|
|
1505
1792
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
|
|
1506
1793
|
.status{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
@@ -1522,9 +1809,10 @@ var HOST_HTML = `<!DOCTYPE html>
|
|
|
1522
1809
|
.input-area{display:flex;gap:8px}
|
|
1523
1810
|
.input-area textarea{flex:1;resize:none;min-height:44px;max-height:120px;padding:10px 14px;border-radius:8px;border:1px solid var(--input-border);background:var(--input-bg);color:var(--text);font-family:inherit;font-size:.9rem}
|
|
1524
1811
|
.terminal-container{background:var(--term-bg);border:1px solid var(--border);border-radius:8px;height:420px;overflow:hidden;margin-bottom:12px}
|
|
1525
|
-
#terminal{width:100%;height:100%;padding:8px}
|
|
1812
|
+
#terminal{width:100%;height:100%;padding:8px;background:var(--term-bg)}
|
|
1813
|
+
#terminal .xterm,#terminal .xterm-viewport{background-color:var(--term-bg) !important}
|
|
1526
1814
|
.toolbar{display:flex;gap:8px;margin-bottom:12px}
|
|
1527
|
-
.tool-btn{padding:6px 12px;font-size:.85rem;background:var(--tool-bg);border:
|
|
1815
|
+
.tool-btn{padding:6px 12px;font-size:.85rem;font-weight:normal;background:var(--tool-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer}
|
|
1528
1816
|
.tool-btn:hover{background:var(--tool-hover)}
|
|
1529
1817
|
</style>
|
|
1530
1818
|
</head>
|
|
@@ -1533,6 +1821,11 @@ var HOST_HTML = `<!DOCTYPE html>
|
|
|
1533
1821
|
<div class="page-header">
|
|
1534
1822
|
<svg viewBox="0 0 100 100" fill="none"><defs><linearGradient id="lg" x1=".3" y1="0" x2=".7" y2="1"><stop stop-color="#a0aaff"/><stop offset="1" stop-color="#6070ef"/></linearGradient></defs><g stroke="url(#lg)" stroke-width="6" stroke-linecap="round"><line x1="22" y1="88" x2="31" y2="64"/><line x1="36" y1="52" x2="50" y2="15"/><line x1="9" y1="58" x2="59.5" y2="58"/><line x1="73.5" y1="58" x2="91" y2="58"/><line x1="50" y1="15" x2="78" y2="88"/></g></svg>
|
|
1535
1823
|
<h1>Airloom</h1>
|
|
1824
|
+
<div class="theme-switch" id="themeSwitch">
|
|
1825
|
+
<button class="theme-btn" data-mode="light" title="Light">Light</button>
|
|
1826
|
+
<button class="theme-btn" data-mode="dark" title="Dark">Dark</button>
|
|
1827
|
+
<button class="theme-btn active" data-mode="system" title="System">Auto</button>
|
|
1828
|
+
</div>
|
|
1536
1829
|
</div>
|
|
1537
1830
|
<div class="card">
|
|
1538
1831
|
<div class="status"><div class="dot wait" id="dot"></div><span id="statusText">Initializing...</span></div>
|
|
@@ -1610,9 +1903,43 @@ const lightTheme = {
|
|
|
1610
1903
|
brightBlack: '#6e6e73', brightRed: '#eb4d3d', brightGreen: '#36b738', brightYellow: '#b79a14',
|
|
1611
1904
|
brightBlue: '#0451a5', brightMagenta: '#c42275', brightCyan: '#318495', brightWhite: '#f2f2f7',
|
|
1612
1905
|
};
|
|
1613
|
-
|
|
1614
|
-
|
|
1906
|
+
|
|
1907
|
+
// --- Theme management ---
|
|
1908
|
+
function isEffectivelyLight() {
|
|
1909
|
+
const mode = document.documentElement.dataset.theme;
|
|
1910
|
+
if (mode === 'light') return true;
|
|
1911
|
+
if (mode === 'dark') return false;
|
|
1912
|
+
return matchMedia('(prefers-color-scheme:light)').matches;
|
|
1913
|
+
}
|
|
1914
|
+
function getTheme() { return isEffectivelyLight() ? lightTheme : darkTheme; }
|
|
1915
|
+
|
|
1916
|
+
function applyTheme(mode) {
|
|
1917
|
+
if (mode === 'light' || mode === 'dark') {
|
|
1918
|
+
document.documentElement.dataset.theme = mode;
|
|
1919
|
+
} else {
|
|
1920
|
+
delete document.documentElement.dataset.theme;
|
|
1921
|
+
mode = 'system';
|
|
1922
|
+
}
|
|
1923
|
+
localStorage.setItem('airloom-theme', mode);
|
|
1615
1924
|
if (term) term.options.theme = getTheme();
|
|
1925
|
+
document.querySelectorAll('.theme-btn').forEach(b => {
|
|
1926
|
+
b.classList.toggle('active', b.dataset.mode === mode);
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Restore saved preference (or default to system)
|
|
1931
|
+
applyTheme(localStorage.getItem('airloom-theme') || 'system');
|
|
1932
|
+
|
|
1933
|
+
document.getElementById('themeSwitch').addEventListener('click', (e) => {
|
|
1934
|
+
const btn = e.target.closest('.theme-btn');
|
|
1935
|
+
if (btn) applyTheme(btn.dataset.mode);
|
|
1936
|
+
});
|
|
1937
|
+
matchMedia('(prefers-color-scheme:light)').addEventListener('change', () => {
|
|
1938
|
+
// Only react if the user chose "system"
|
|
1939
|
+
const saved = localStorage.getItem('airloom-theme');
|
|
1940
|
+
if (!saved || saved === 'system') {
|
|
1941
|
+
if (term) term.options.theme = getTheme();
|
|
1942
|
+
}
|
|
1616
1943
|
});
|
|
1617
1944
|
|
|
1618
1945
|
function initTerminal() {
|
|
@@ -1642,6 +1969,10 @@ function initTerminal() {
|
|
|
1642
1969
|
ws.send(JSON.stringify({ type: 'terminal_resize', cols: term.cols, rows: term.rows }));
|
|
1643
1970
|
}
|
|
1644
1971
|
}).observe(document.getElementById('terminalContainer'));
|
|
1972
|
+
// Prevent wheel events from leaking to the page when the terminal is at its
|
|
1973
|
+
// scroll bounds. xterm.js v6 uses a SmoothScrollableElement that doesn't
|
|
1974
|
+
// always consume wheel events at the extents.
|
|
1975
|
+
document.getElementById('terminalContainer').addEventListener('wheel', (e) => { e.preventDefault(); }, { passive: false });
|
|
1645
1976
|
if (ws.readyState === WebSocket.OPEN) sendTerminalOpen();
|
|
1646
1977
|
}
|
|
1647
1978
|
|
|
@@ -1794,13 +2125,114 @@ function sendMessage() {
|
|
|
1794
2125
|
</body>
|
|
1795
2126
|
</html>`;
|
|
1796
2127
|
|
|
2128
|
+
// src/env.ts
|
|
2129
|
+
import { isIP } from "node:net";
|
|
2130
|
+
var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
|
|
2131
|
+
var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
|
|
2132
|
+
var DEFAULT_ABLY_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2133
|
+
var DEFAULT_HOST_BIND = "127.0.0.1";
|
|
2134
|
+
var DEFAULT_HOST_PORT = 4e3;
|
|
2135
|
+
function parseInteger(name, value, fallback, min, max) {
|
|
2136
|
+
if (value === void 0 || value === "") return fallback;
|
|
2137
|
+
const parsed = Number.parseInt(value, 10);
|
|
2138
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
2139
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
2140
|
+
}
|
|
2141
|
+
return parsed;
|
|
2142
|
+
}
|
|
2143
|
+
function parseViewerUrl(value) {
|
|
2144
|
+
const candidate = value?.trim() || DEFAULT_VIEWER_URL;
|
|
2145
|
+
let parsed;
|
|
2146
|
+
try {
|
|
2147
|
+
parsed = new URL(candidate);
|
|
2148
|
+
} catch {
|
|
2149
|
+
throw new Error("VIEWER_URL must be a valid http:// or https:// URL");
|
|
2150
|
+
}
|
|
2151
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2152
|
+
throw new Error("VIEWER_URL must use http:// or https://");
|
|
2153
|
+
}
|
|
2154
|
+
return parsed.toString();
|
|
2155
|
+
}
|
|
2156
|
+
function parseRelayUrl(value) {
|
|
2157
|
+
if (!value?.trim()) return void 0;
|
|
2158
|
+
let parsed;
|
|
2159
|
+
try {
|
|
2160
|
+
parsed = new URL(value.trim());
|
|
2161
|
+
} catch {
|
|
2162
|
+
throw new Error("RELAY_URL must be a valid ws:// or wss:// URL");
|
|
2163
|
+
}
|
|
2164
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
2165
|
+
throw new Error("RELAY_URL must use ws:// or wss://");
|
|
2166
|
+
}
|
|
2167
|
+
return parsed.toString();
|
|
2168
|
+
}
|
|
2169
|
+
function parseHostBind(value, isDev) {
|
|
2170
|
+
const bind = value?.trim() || (isDev ? "0.0.0.0" : DEFAULT_HOST_BIND);
|
|
2171
|
+
if (bind === "localhost" || isIP(bind) !== 0) return bind;
|
|
2172
|
+
if (/^[A-Za-z0-9.-]+$/.test(bind)) return bind;
|
|
2173
|
+
throw new Error("HOST_BIND must be localhost, an IP address, or a hostname");
|
|
2174
|
+
}
|
|
2175
|
+
function isLoopbackBind(bind) {
|
|
2176
|
+
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
|
|
2177
|
+
}
|
|
2178
|
+
function parseHostEnv(cliPort, isDev = false) {
|
|
2179
|
+
const relayUrl = parseRelayUrl(process.env.RELAY_URL);
|
|
2180
|
+
const ablyApiKey = process.env.ABLY_API_KEY ?? (relayUrl ? void 0 : DEFAULT_ABLY_KEY);
|
|
2181
|
+
const ablyTokenTtlMs = parseInteger("ABLY_TOKEN_TTL", process.env.ABLY_TOKEN_TTL, DEFAULT_ABLY_TOKEN_TTL_MS, 6e4, 31 * 24 * 60 * 60 * 1e3);
|
|
2182
|
+
const hostPort = cliPort ?? parseInteger("HOST_PORT", process.env.HOST_PORT, DEFAULT_HOST_PORT, 0, 65535);
|
|
2183
|
+
const hostBind = parseHostBind(process.env.HOST_BIND, isDev);
|
|
2184
|
+
const viewerUrl = parseViewerUrl(process.env.VIEWER_URL);
|
|
2185
|
+
return {
|
|
2186
|
+
viewerUrl,
|
|
2187
|
+
relayUrl,
|
|
2188
|
+
ablyApiKey,
|
|
2189
|
+
ablyTokenTtlMs,
|
|
2190
|
+
hostPort,
|
|
2191
|
+
hostBind,
|
|
2192
|
+
useAbly: !!ablyApiKey,
|
|
2193
|
+
isDefaultAblyKey: !!ablyApiKey && ablyApiKey === DEFAULT_ABLY_KEY
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/state.ts
|
|
2198
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2199
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2200
|
+
import { homedir as homedir2 } from "node:os";
|
|
2201
|
+
import { join as join4 } from "node:path";
|
|
2202
|
+
var STATE_DIR = join4(homedir2(), ".config", "airloom");
|
|
2203
|
+
var STATE_PATH = join4(STATE_DIR, "state.json");
|
|
2204
|
+
function readState() {
|
|
2205
|
+
try {
|
|
2206
|
+
const raw = readFileSync2(STATE_PATH, "utf-8");
|
|
2207
|
+
const data = JSON.parse(raw);
|
|
2208
|
+
return data && typeof data === "object" ? data : {};
|
|
2209
|
+
} catch {
|
|
2210
|
+
return {};
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
function writeState(state) {
|
|
2214
|
+
mkdirSync2(STATE_DIR, { recursive: true });
|
|
2215
|
+
writeFileSync2(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
2216
|
+
}
|
|
2217
|
+
function isSessionToken(value) {
|
|
2218
|
+
return typeof value === "string" && /^[0-9a-f]{32}$/i.test(value);
|
|
2219
|
+
}
|
|
2220
|
+
function loadOrCreateAblySessionToken() {
|
|
2221
|
+
const state = readState();
|
|
2222
|
+
if (isSessionToken(state.ablySessionToken)) return state.ablySessionToken;
|
|
2223
|
+
const ablySessionToken = randomBytes2(16).toString("hex");
|
|
2224
|
+
state.ablySessionToken = ablySessionToken;
|
|
2225
|
+
writeState(state);
|
|
2226
|
+
return ablySessionToken;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
1797
2229
|
// src/index.ts
|
|
1798
2230
|
var QRCode = null;
|
|
1799
2231
|
async function getQRCode() {
|
|
1800
2232
|
if (!QRCode) QRCode = await import("qrcode");
|
|
1801
2233
|
return QRCode;
|
|
1802
2234
|
}
|
|
1803
|
-
|
|
2235
|
+
log("[host] Module loaded");
|
|
1804
2236
|
function parseArgs(argv) {
|
|
1805
2237
|
const args = {};
|
|
1806
2238
|
const rest = argv.slice(2);
|
|
@@ -1851,7 +2283,8 @@ Environment variables:
|
|
|
1851
2283
|
ABLY_API_KEY Your own Ably key (overrides default community relay).
|
|
1852
2284
|
RELAY_URL Self-hosted WebSocket relay URL (disables Ably).
|
|
1853
2285
|
VIEWER_URL Public viewer URL (default: GitHub Pages).
|
|
1854
|
-
HOST_PORT Same as --port (
|
|
2286
|
+
HOST_PORT Same as --port (default: 4000, auto-increments if in use).
|
|
2287
|
+
HOST_BIND Host bind address (default: 127.0.0.1).
|
|
1855
2288
|
`.trimStart());
|
|
1856
2289
|
}
|
|
1857
2290
|
var cliArgs = parseArgs(process.argv);
|
|
@@ -1859,16 +2292,16 @@ if (cliArgs.help) {
|
|
|
1859
2292
|
printHelp();
|
|
1860
2293
|
process.exit(0);
|
|
1861
2294
|
}
|
|
1862
|
-
var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
|
|
1863
|
-
var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
|
|
1864
|
-
var VIEWER_URL = process.env.VIEWER_URL ?? DEFAULT_VIEWER_URL;
|
|
1865
2295
|
var IS_DEV = !process.env.VIEWER_URL && !new URL(import.meta.url).pathname.includes("node_modules");
|
|
1866
|
-
var
|
|
1867
|
-
var
|
|
1868
|
-
var
|
|
1869
|
-
var
|
|
1870
|
-
var
|
|
1871
|
-
var
|
|
2296
|
+
var env = parseHostEnv(cliArgs.port, IS_DEV);
|
|
2297
|
+
var VIEWER_URL = env.viewerUrl;
|
|
2298
|
+
var RELAY_URL = env.relayUrl;
|
|
2299
|
+
var ABLY_API_KEY = env.ablyApiKey;
|
|
2300
|
+
var ABLY_TOKEN_TTL = env.ablyTokenTtlMs;
|
|
2301
|
+
var HOST_PORT = env.hostPort;
|
|
2302
|
+
var HOST_BIND = env.hostBind;
|
|
2303
|
+
var useAbly = env.useAbly;
|
|
2304
|
+
var isDefaultKey = env.isDefaultAblyKey;
|
|
1872
2305
|
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1873
2306
|
function getLanIP() {
|
|
1874
2307
|
for (const ifaces of Object.values(networkInterfaces())) {
|
|
@@ -1890,41 +2323,58 @@ async function main() {
|
|
|
1890
2323
|
console.log("==============\n");
|
|
1891
2324
|
if (useAbly) {
|
|
1892
2325
|
if (isDefaultKey) {
|
|
1893
|
-
|
|
1894
|
-
|
|
2326
|
+
log("Transport: Ably (community relay \u2014 shared quota)");
|
|
2327
|
+
log(" Set ABLY_API_KEY for your own quota, or RELAY_URL for self-hosted.\n");
|
|
1895
2328
|
} else {
|
|
1896
|
-
|
|
2329
|
+
log("Transport: Ably (your key)");
|
|
1897
2330
|
}
|
|
1898
2331
|
} else {
|
|
1899
|
-
|
|
2332
|
+
log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
|
|
1900
2333
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
2334
|
+
let pairingCode;
|
|
2335
|
+
let pairingSessionToken;
|
|
2336
|
+
let relaySessionToken;
|
|
1903
2337
|
let pairingData;
|
|
2338
|
+
let ablyToken;
|
|
2339
|
+
let ablyTokenExpiresAt;
|
|
1904
2340
|
if (useAbly) {
|
|
2341
|
+
pairingCode = randomCode(8);
|
|
2342
|
+
pairingSessionToken = deriveSessionToken(pairingCode);
|
|
2343
|
+
relaySessionToken = loadOrCreateAblySessionToken();
|
|
2344
|
+
const keyPair = generateKeyPair();
|
|
1905
2345
|
const { Rest } = await import("ably");
|
|
1906
2346
|
const rest = new Rest({
|
|
1907
2347
|
key: ABLY_API_KEY,
|
|
1908
2348
|
queryTime: true
|
|
1909
2349
|
});
|
|
1910
|
-
const channelName = `airloom:${
|
|
2350
|
+
const channelName = `airloom:${relaySessionToken}`;
|
|
1911
2351
|
const tokenDetails = await rest.auth.requestToken({
|
|
1912
2352
|
clientId: "*",
|
|
1913
|
-
|
|
1914
|
-
capability: { "airloom:*": ["publish", "subscribe", "presence"] },
|
|
2353
|
+
capability: { [channelName]: ["publish", "subscribe", "presence"] },
|
|
1915
2354
|
ttl: ABLY_TOKEN_TTL
|
|
1916
2355
|
});
|
|
1917
|
-
|
|
2356
|
+
log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
|
|
2357
|
+
ablyToken = tokenDetails.token;
|
|
2358
|
+
ablyTokenExpiresAt = tokenDetails.expires;
|
|
1918
2359
|
pairingData = {
|
|
1919
|
-
|
|
2360
|
+
relay: "ably",
|
|
2361
|
+
session: relaySessionToken,
|
|
2362
|
+
pub: toBase64(keyPair.publicKey),
|
|
2363
|
+
v: 1,
|
|
1920
2364
|
transport: "ably",
|
|
1921
|
-
token:
|
|
2365
|
+
token: ablyToken,
|
|
2366
|
+
tokenExpiresAt: ablyTokenExpiresAt
|
|
1922
2367
|
};
|
|
1923
2368
|
} else {
|
|
2369
|
+
const session = createSession(RELAY_URL);
|
|
2370
|
+
pairingCode = session.pairingCode;
|
|
2371
|
+
pairingSessionToken = session.sessionToken;
|
|
2372
|
+
relaySessionToken = session.sessionToken;
|
|
1924
2373
|
pairingData = { ...session.pairingData };
|
|
1925
2374
|
}
|
|
2375
|
+
const displayCode = formatPairingCode(pairingCode);
|
|
1926
2376
|
const pairingJSON = encodePairingData(pairingData);
|
|
1927
|
-
const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" +
|
|
2377
|
+
const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" + relaySessionToken));
|
|
1928
2378
|
const encryptionKey = deriveEncryptionKey(keyMaterial);
|
|
1929
2379
|
let adapter;
|
|
1930
2380
|
if (useAbly) {
|
|
@@ -1937,12 +2387,12 @@ async function main() {
|
|
|
1937
2387
|
role: "host",
|
|
1938
2388
|
encryptionKey
|
|
1939
2389
|
});
|
|
1940
|
-
await channel.connect(
|
|
1941
|
-
|
|
2390
|
+
await channel.connect(relaySessionToken);
|
|
2391
|
+
log("[host] Connected to relay, waiting for phone...");
|
|
1942
2392
|
const savedConfig = loadConfig();
|
|
1943
2393
|
const launchPreset = cliArgs.preset ? CLI_PRESETS.find((p) => p.id === cliArgs.preset) : void 0;
|
|
1944
2394
|
if (cliArgs.preset && !launchPreset) {
|
|
1945
|
-
|
|
2395
|
+
logError(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
|
|
1946
2396
|
process.exit(1);
|
|
1947
2397
|
}
|
|
1948
2398
|
const savedTerminalCommand = !cliArgs.cli && !cliArgs.preset && savedConfig?.type === "terminal" ? savedConfig.command ?? (savedConfig.preset && savedConfig.preset !== "shell" ? CLI_PRESETS.find((p) => p.id === savedConfig.preset)?.command : void 0) : void 0;
|
|
@@ -1959,11 +2409,13 @@ async function main() {
|
|
|
1959
2409
|
terminalLaunch,
|
|
1960
2410
|
terminalLaunchCommand: launchCommand,
|
|
1961
2411
|
messages: [],
|
|
1962
|
-
|
|
1963
|
-
|
|
2412
|
+
pairingSessionToken,
|
|
2413
|
+
relaySessionToken,
|
|
2414
|
+
ablyToken,
|
|
2415
|
+
ablyTokenExpiresAt,
|
|
1964
2416
|
transport: useAbly ? "ably" : "ws"
|
|
1965
2417
|
};
|
|
1966
|
-
|
|
2418
|
+
log(`[host] Terminal launch: ${terminalLaunch}`);
|
|
1967
2419
|
if (cliArgs.cli || cliArgs.preset) {
|
|
1968
2420
|
let command = cliArgs.cli;
|
|
1969
2421
|
const presetInfo = launchPreset;
|
|
@@ -1974,7 +2426,7 @@ async function main() {
|
|
|
1974
2426
|
mode: presetInfo?.mode,
|
|
1975
2427
|
silenceTimeout: presetInfo?.silenceTimeout
|
|
1976
2428
|
});
|
|
1977
|
-
|
|
2429
|
+
log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
|
|
1978
2430
|
}
|
|
1979
2431
|
} else {
|
|
1980
2432
|
const saved = loadConfig();
|
|
@@ -2010,28 +2462,35 @@ async function main() {
|
|
|
2010
2462
|
}
|
|
2011
2463
|
}
|
|
2012
2464
|
if (state.adapter) {
|
|
2013
|
-
|
|
2014
|
-
|
|
2465
|
+
log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
|
|
2466
|
+
log(` Loaded from ${getConfigPath()}`);
|
|
2015
2467
|
}
|
|
2016
2468
|
} catch (err) {
|
|
2017
|
-
|
|
2469
|
+
logError("[host] Auto-configure failed:", err.message);
|
|
2018
2470
|
}
|
|
2019
2471
|
}
|
|
2020
2472
|
}
|
|
2021
2473
|
const viewerDir = resolveViewerDir();
|
|
2022
2474
|
if (viewerDir) {
|
|
2023
|
-
|
|
2475
|
+
log(`[host] Viewer files: ${viewerDir}`);
|
|
2024
2476
|
} else {
|
|
2025
|
-
|
|
2026
|
-
}
|
|
2027
|
-
const
|
|
2477
|
+
log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
|
|
2478
|
+
}
|
|
2479
|
+
const controlToken = createControlToken();
|
|
2480
|
+
const { server, broadcast, port } = await createHostServer({
|
|
2481
|
+
port: HOST_PORT,
|
|
2482
|
+
bind: HOST_BIND,
|
|
2483
|
+
controlToken,
|
|
2484
|
+
state,
|
|
2485
|
+
viewerDir
|
|
2486
|
+
});
|
|
2028
2487
|
const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
|
|
2029
2488
|
const viewerBase = VIEWER_URL.replace(/\/+$/, "");
|
|
2030
2489
|
const pagesUrl = `${viewerBase}/#${pairingBase64}`;
|
|
2031
2490
|
const lanIP = getLanIP();
|
|
2032
2491
|
const lanHost = lanIP ?? "localhost";
|
|
2033
2492
|
const lanBaseUrl = `http://${lanHost}:${port}`;
|
|
2034
|
-
const lanViewerUrl = viewerDir ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
|
|
2493
|
+
const lanViewerUrl = viewerDir && !isLoopbackBind(HOST_BIND) ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
|
|
2035
2494
|
const qrTarget = IS_DEV && lanViewerUrl ? lanViewerUrl : pagesUrl;
|
|
2036
2495
|
const qrcode = await getQRCode();
|
|
2037
2496
|
const qrDataUrl = await qrcode.toDataURL(qrTarget, { width: 300, margin: 2 });
|
|
@@ -2049,36 +2508,50 @@ async function main() {
|
|
|
2049
2508
|
}
|
|
2050
2509
|
if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
|
|
2051
2510
|
const localUrl = `http://localhost:${port}`;
|
|
2052
|
-
|
|
2511
|
+
const controlUrl = encodeControlUrl(localUrl, controlToken);
|
|
2512
|
+
log(`[host] Web UI at ${controlUrl}
|
|
2053
2513
|
`);
|
|
2054
2514
|
import("node:child_process").then(({ exec }) => {
|
|
2055
2515
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2056
|
-
exec(`${cmd} ${
|
|
2516
|
+
exec(`${cmd} ${controlUrl}`);
|
|
2057
2517
|
}).catch(() => {
|
|
2058
2518
|
});
|
|
2059
2519
|
const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand, broadcast);
|
|
2060
2520
|
state.terminal = terminal;
|
|
2521
|
+
const pushViewerSession = () => {
|
|
2522
|
+
if (!state.relaySessionToken || !state.transport) return;
|
|
2523
|
+
const refresh = {
|
|
2524
|
+
type: "session_refresh",
|
|
2525
|
+
relay: state.relayUrl,
|
|
2526
|
+
session: state.relaySessionToken,
|
|
2527
|
+
transport: state.transport,
|
|
2528
|
+
token: state.ablyToken,
|
|
2529
|
+
tokenExpiresAt: state.ablyTokenExpiresAt
|
|
2530
|
+
};
|
|
2531
|
+
channel.send(refresh);
|
|
2532
|
+
};
|
|
2061
2533
|
channel.on("ready", () => {
|
|
2062
|
-
|
|
2534
|
+
log("[host] Phone connected! Channel ready.");
|
|
2063
2535
|
state.connected = true;
|
|
2064
2536
|
broadcast({ type: "peer_connected" });
|
|
2537
|
+
pushViewerSession();
|
|
2065
2538
|
});
|
|
2066
2539
|
channel.on("peer_left", () => {
|
|
2067
|
-
|
|
2540
|
+
log("[host] Phone disconnected.");
|
|
2068
2541
|
state.connected = false;
|
|
2069
2542
|
terminal.detachStream();
|
|
2070
2543
|
broadcast({ type: "peer_disconnected" });
|
|
2071
2544
|
});
|
|
2072
2545
|
channel.on("message", (data) => {
|
|
2073
2546
|
if (isTerminalMessage(data)) {
|
|
2074
|
-
|
|
2547
|
+
log("[host] Terminal message from phone:", data.type);
|
|
2075
2548
|
terminal.handleMessage(data);
|
|
2076
2549
|
return;
|
|
2077
2550
|
}
|
|
2078
2551
|
if (typeof data === "object" && data !== null && "type" in data && "content" in data) {
|
|
2079
2552
|
const msg = data;
|
|
2080
2553
|
if (msg.type === "chat" && typeof msg.content === "string") {
|
|
2081
|
-
|
|
2554
|
+
log(`[phone] ${msg.content}`);
|
|
2082
2555
|
state.messages.push({ role: "user", content: msg.content, timestamp: Date.now() });
|
|
2083
2556
|
broadcast({ type: "message", role: "user", content: msg.content });
|
|
2084
2557
|
if (state.adapter) {
|
|
@@ -2087,12 +2560,12 @@ async function main() {
|
|
|
2087
2560
|
}
|
|
2088
2561
|
}
|
|
2089
2562
|
});
|
|
2090
|
-
channel.on("error", (err) =>
|
|
2563
|
+
channel.on("error", (err) => logError("[host] Channel error:", err.message));
|
|
2091
2564
|
let shuttingDown = false;
|
|
2092
2565
|
const shutdown = () => {
|
|
2093
2566
|
if (shuttingDown) return;
|
|
2094
2567
|
shuttingDown = true;
|
|
2095
|
-
|
|
2568
|
+
log("\n[host] Shutting down...");
|
|
2096
2569
|
terminal.destroy();
|
|
2097
2570
|
state.adapter?.destroy?.();
|
|
2098
2571
|
try {
|
|
@@ -2106,6 +2579,6 @@ async function main() {
|
|
|
2106
2579
|
process.on("SIGTERM", shutdown);
|
|
2107
2580
|
}
|
|
2108
2581
|
main().catch((err) => {
|
|
2109
|
-
|
|
2582
|
+
logError("Fatal error:", err);
|
|
2110
2583
|
process.exit(1);
|
|
2111
2584
|
});
|