codex-blocker 0.1.2 → 0.1.3
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/README.md +7 -7
- package/dist/bin.js +9 -2
- package/dist/{chunk-KNFNSOAX.js → chunk-ZDUKZXM4.js} +528 -42
- package/dist/server.d.ts +43 -2
- package/dist/server.js +5 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# codex-blocker
|
|
2
2
|
|
|
3
|
-
CLI tool and server for Codex Blocker
|
|
3
|
+
CLI tool and server for Codex Blocker -- block distracting websites unless Codex is actively running.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -41,18 +41,18 @@ npx codex-blocker --version
|
|
|
41
41
|
|
|
42
42
|
## How It Works
|
|
43
43
|
|
|
44
|
-
1. **Codex sessions**
|
|
45
|
-
to detect activity. It marks a session
|
|
44
|
+
1. **Codex sessions** -- The server tails Codex session logs under `~/.codex/sessions`
|
|
45
|
+
to detect activity. It marks a session "working" on your prompt and on intermediate
|
|
46
46
|
assistant/tool activity, marks `waiting_for_input` when Codex emits
|
|
47
|
-
`request_user_input`, and marks
|
|
47
|
+
`request_user_input`, and marks "idle" when it sees a terminal assistant reply
|
|
48
48
|
(`phase: "final_answer"`), with legacy fallback support for older Codex logs.
|
|
49
49
|
|
|
50
|
-
2. **Server**
|
|
50
|
+
2. **Server** -- Runs on localhost and:
|
|
51
51
|
- Tracks active Codex sessions
|
|
52
52
|
- Marks sessions "working" when new log lines arrive
|
|
53
53
|
- Broadcasts state via WebSocket to the Chrome extension
|
|
54
54
|
|
|
55
|
-
3. **Extension**
|
|
55
|
+
3. **Extension** -- Connects to the server and:
|
|
56
56
|
- Blocks configured sites when no sessions are working, or when any session is waiting for user input
|
|
57
57
|
- Shows a modal overlay (soft block, not network block)
|
|
58
58
|
- Updates in real-time without page refresh
|
|
@@ -82,7 +82,7 @@ Connect to `ws://localhost:8765/ws` to receive real-time state updates:
|
|
|
82
82
|
## Programmatic Usage
|
|
83
83
|
|
|
84
84
|
```typescript
|
|
85
|
-
import { startServer } from
|
|
85
|
+
import { startServer } from "codex-blocker";
|
|
86
86
|
|
|
87
87
|
// Start on default port (8765)
|
|
88
88
|
startServer();
|
package/dist/bin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_PORT,
|
|
4
4
|
startServer
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-ZDUKZXM4.js";
|
|
6
6
|
|
|
7
7
|
// src/bin.ts
|
|
8
8
|
import { createRequire } from "module";
|
|
@@ -43,6 +43,10 @@ function removeCodexSetup() {
|
|
|
43
43
|
var require2 = createRequire(import.meta.url);
|
|
44
44
|
var { version } = require2("../package.json");
|
|
45
45
|
var args = process.argv.slice(2);
|
|
46
|
+
function isWindowsOrWslRuntime() {
|
|
47
|
+
if (process.platform === "win32") return true;
|
|
48
|
+
return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
49
|
+
}
|
|
46
50
|
function prompt(question) {
|
|
47
51
|
const rl = createInterface({
|
|
48
52
|
input: process.stdin,
|
|
@@ -109,6 +113,9 @@ async function main() {
|
|
|
109
113
|
console.log("");
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
|
-
startServer(port
|
|
116
|
+
startServer(port, {
|
|
117
|
+
mobile: false,
|
|
118
|
+
bindHost: isWindowsOrWslRuntime() ? "0.0.0.0" : "127.0.0.1"
|
|
119
|
+
});
|
|
113
120
|
}
|
|
114
121
|
main();
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
2
|
import { createServer } from "http";
|
|
3
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
-
import { homedir as homedir2 } from "os";
|
|
5
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
6
3
|
import { WebSocketServer, WebSocket } from "ws";
|
|
7
4
|
|
|
8
5
|
// src/types.ts
|
|
9
6
|
var DEFAULT_PORT = 8765;
|
|
10
7
|
var SESSION_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
11
8
|
var CODEX_SESSIONS_SCAN_INTERVAL_MS = 2e3;
|
|
9
|
+
var MOBILE_PAIRING_TTL_MS = 2 * 60 * 1e3;
|
|
10
|
+
var MOBILE_QR_PAIRING_TTL_MS = 60 * 1e3;
|
|
12
11
|
|
|
13
12
|
// src/state.ts
|
|
14
13
|
var SessionState = class {
|
|
@@ -442,31 +441,189 @@ var CodexSessionWatcher = class {
|
|
|
442
441
|
}
|
|
443
442
|
};
|
|
444
443
|
|
|
445
|
-
// src/
|
|
444
|
+
// src/mobile.ts
|
|
445
|
+
import { randomBytes, randomInt } from "crypto";
|
|
446
|
+
var ExtensionPairingManager = class {
|
|
447
|
+
constructor(now = () => Date.now()) {
|
|
448
|
+
this.now = now;
|
|
449
|
+
}
|
|
450
|
+
pairing = null;
|
|
451
|
+
startPairing(regenerateCode = false) {
|
|
452
|
+
this.expireIfNeeded();
|
|
453
|
+
if (this.pairing && !regenerateCode) {
|
|
454
|
+
return { ...this.pairing };
|
|
455
|
+
}
|
|
456
|
+
const next = {
|
|
457
|
+
code: randomInt(0, 1e6).toString().padStart(6, "0"),
|
|
458
|
+
expiresAt: this.now() + MOBILE_PAIRING_TTL_MS
|
|
459
|
+
};
|
|
460
|
+
this.pairing = next;
|
|
461
|
+
return { ...next };
|
|
462
|
+
}
|
|
463
|
+
getStatus() {
|
|
464
|
+
this.expireIfNeeded();
|
|
465
|
+
return {
|
|
466
|
+
active: this.pairing !== null,
|
|
467
|
+
expiresAt: this.pairing?.expiresAt ?? null
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
confirmPairingCode(code) {
|
|
471
|
+
this.expireIfNeeded();
|
|
472
|
+
if (!this.pairing) return false;
|
|
473
|
+
if (code.trim() !== this.pairing.code) return false;
|
|
474
|
+
this.pairing = null;
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
expireIfNeeded() {
|
|
478
|
+
if (!this.pairing) return;
|
|
479
|
+
if (this.now() >= this.pairing.expiresAt) {
|
|
480
|
+
this.pairing = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
var MobileQrPairingManager = class {
|
|
485
|
+
constructor(now = () => Date.now()) {
|
|
486
|
+
this.now = now;
|
|
487
|
+
}
|
|
488
|
+
pairing = null;
|
|
489
|
+
startPairing(refreshQr = false) {
|
|
490
|
+
this.expireIfNeeded();
|
|
491
|
+
if (this.pairing && !refreshQr) {
|
|
492
|
+
return { ...this.pairing };
|
|
493
|
+
}
|
|
494
|
+
if (this.pairing && refreshQr) {
|
|
495
|
+
const nextQrExpiry = this.now() + MOBILE_QR_PAIRING_TTL_MS;
|
|
496
|
+
const refreshed = {
|
|
497
|
+
...this.pairing,
|
|
498
|
+
qrNonce: randomBytes(16).toString("hex"),
|
|
499
|
+
qrExpiresAt: Math.max(nextQrExpiry, this.pairing.qrExpiresAt + 1)
|
|
500
|
+
};
|
|
501
|
+
this.pairing = refreshed;
|
|
502
|
+
return { ...refreshed };
|
|
503
|
+
}
|
|
504
|
+
const next = {
|
|
505
|
+
expiresAt: this.now() + MOBILE_PAIRING_TTL_MS,
|
|
506
|
+
qrNonce: randomBytes(16).toString("hex"),
|
|
507
|
+
qrExpiresAt: this.now() + MOBILE_QR_PAIRING_TTL_MS
|
|
508
|
+
};
|
|
509
|
+
this.pairing = next;
|
|
510
|
+
return { ...next };
|
|
511
|
+
}
|
|
512
|
+
getStatus() {
|
|
513
|
+
this.expireIfNeeded();
|
|
514
|
+
return {
|
|
515
|
+
active: this.pairing !== null,
|
|
516
|
+
expiresAt: this.pairing?.expiresAt ?? null
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
confirmPairingQrNonce(qrNonce) {
|
|
520
|
+
this.expireIfNeeded();
|
|
521
|
+
if (!this.pairing) return false;
|
|
522
|
+
if (this.now() >= this.pairing.qrExpiresAt) return false;
|
|
523
|
+
if (qrNonce.trim() !== this.pairing.qrNonce) return false;
|
|
524
|
+
this.pairing = null;
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
expireIfNeeded() {
|
|
528
|
+
if (!this.pairing) return;
|
|
529
|
+
if (this.now() >= this.pairing.expiresAt) {
|
|
530
|
+
this.pairing = null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
function createServerInstanceId() {
|
|
535
|
+
return randomBytes(8).toString("hex");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/mdns.ts
|
|
539
|
+
import { Bonjour } from "bonjour-service";
|
|
540
|
+
function publishMobileService({
|
|
541
|
+
name,
|
|
542
|
+
type,
|
|
543
|
+
port,
|
|
544
|
+
instanceId
|
|
545
|
+
}) {
|
|
546
|
+
const bonjour = new Bonjour();
|
|
547
|
+
const service = bonjour.publish({
|
|
548
|
+
name,
|
|
549
|
+
type,
|
|
550
|
+
port,
|
|
551
|
+
txt: {
|
|
552
|
+
instanceId,
|
|
553
|
+
version: "1"
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
stop: () => new Promise((resolve) => {
|
|
558
|
+
const maybeService = service;
|
|
559
|
+
if (!maybeService || typeof maybeService.stop !== "function") {
|
|
560
|
+
bonjour.destroy();
|
|
561
|
+
resolve();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
maybeService.stop(() => {
|
|
565
|
+
bonjour.destroy();
|
|
566
|
+
resolve();
|
|
567
|
+
});
|
|
568
|
+
})
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/auth-token.ts
|
|
573
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
574
|
+
import { homedir as homedir2 } from "os";
|
|
575
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
446
576
|
var DEFAULT_TOKEN_DIR = join2(homedir2(), ".codex-blocker");
|
|
447
577
|
var DEFAULT_TOKEN_PATH = join2(DEFAULT_TOKEN_DIR, "token");
|
|
578
|
+
function createAuthToken() {
|
|
579
|
+
return randomBytes2(32).toString("hex");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/pairing-qr.ts
|
|
583
|
+
import QRCode from "qrcode";
|
|
584
|
+
var PAIRING_QR_FORMAT = "cbm-v1";
|
|
585
|
+
var PAIRING_QR_PREFIX = "CBM1";
|
|
586
|
+
function encodePairingQrPayload(input) {
|
|
587
|
+
const host = encodeURIComponent(input.host);
|
|
588
|
+
const instanceId = encodeURIComponent(input.instanceId);
|
|
589
|
+
const qrNonce = encodeURIComponent(input.qrNonce);
|
|
590
|
+
return `${PAIRING_QR_PREFIX};h=${host};p=${input.port};i=${instanceId};n=${qrNonce};e=${input.expiresAt}`;
|
|
591
|
+
}
|
|
592
|
+
async function renderPairingQr(payload) {
|
|
593
|
+
return QRCode.toString(payload, { type: "terminal", small: true });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/server.ts
|
|
448
597
|
var RATE_WINDOW_MS = 6e4;
|
|
449
598
|
var RATE_LIMIT = 60;
|
|
450
599
|
var MAX_WS_CONNECTIONS_PER_IP = 3;
|
|
600
|
+
var MOBILE_SERVICE_TYPE = "codex-blocker";
|
|
601
|
+
var PAIR_CONFIRM_WINDOW_MS = 6e4;
|
|
602
|
+
var PAIR_CONFIRM_MAX_FAILURES = 6;
|
|
603
|
+
var PAIR_CONFIRM_LOCKOUT_MS = 2 * 6e4;
|
|
604
|
+
var WS_TOKEN_PROTOCOL_PREFIX = "codex-blocker-token.";
|
|
605
|
+
var INVALID_JSON_SENTINEL = /* @__PURE__ */ Symbol("invalid-json");
|
|
451
606
|
var rateByIp = /* @__PURE__ */ new Map();
|
|
452
607
|
var wsConnectionsByIp = /* @__PURE__ */ new Map();
|
|
453
|
-
|
|
454
|
-
|
|
608
|
+
var extensionPairConfirmByIp = /* @__PURE__ */ new Map();
|
|
609
|
+
var mobilePairConfirmByIp = /* @__PURE__ */ new Map();
|
|
610
|
+
var CHROME_EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
|
|
611
|
+
function isTrustedChromeExtensionOrigin(origin) {
|
|
612
|
+
if (!origin) return false;
|
|
455
613
|
try {
|
|
456
|
-
|
|
614
|
+
const parsed = new URL(origin);
|
|
615
|
+
return parsed.protocol === "chrome-extension:" && CHROME_EXTENSION_ID_PATTERN.test(parsed.hostname);
|
|
457
616
|
} catch {
|
|
458
|
-
return
|
|
617
|
+
return false;
|
|
459
618
|
}
|
|
460
619
|
}
|
|
461
|
-
function
|
|
462
|
-
|
|
463
|
-
if (!existsSync2(tokenDir)) {
|
|
464
|
-
mkdirSync(tokenDir, { recursive: true });
|
|
465
|
-
}
|
|
466
|
-
writeFileSync(tokenPath, token, "utf-8");
|
|
620
|
+
function canBootstrapExtensionToken(providedToken, allowExtensionOrigin) {
|
|
621
|
+
return Boolean(providedToken && allowExtensionOrigin);
|
|
467
622
|
}
|
|
468
|
-
function
|
|
469
|
-
|
|
623
|
+
function isLoopbackClientIp(clientIp) {
|
|
624
|
+
if (!clientIp) return false;
|
|
625
|
+
const normalized = clientIp.startsWith("::ffff:") ? clientIp.slice(7) : clientIp;
|
|
626
|
+
return normalized === "127.0.0.1" || normalized === "::1";
|
|
470
627
|
}
|
|
471
628
|
function getClientIp(req) {
|
|
472
629
|
return req.socket.remoteAddress ?? "unknown";
|
|
@@ -482,6 +639,40 @@ function checkRateLimit(ip) {
|
|
|
482
639
|
state2.count += 1;
|
|
483
640
|
return true;
|
|
484
641
|
}
|
|
642
|
+
function getPairConfirmState(lockoutStore, ip) {
|
|
643
|
+
const now = Date.now();
|
|
644
|
+
const current = lockoutStore.get(ip);
|
|
645
|
+
if (!current || current.resetAt <= now) {
|
|
646
|
+
const next = {
|
|
647
|
+
failures: 0,
|
|
648
|
+
resetAt: now + PAIR_CONFIRM_WINDOW_MS,
|
|
649
|
+
lockoutUntil: 0
|
|
650
|
+
};
|
|
651
|
+
lockoutStore.set(ip, next);
|
|
652
|
+
return next;
|
|
653
|
+
}
|
|
654
|
+
return current;
|
|
655
|
+
}
|
|
656
|
+
function canAttemptPairConfirm(lockoutStore, ip) {
|
|
657
|
+
const state2 = getPairConfirmState(lockoutStore, ip);
|
|
658
|
+
return state2.lockoutUntil <= Date.now();
|
|
659
|
+
}
|
|
660
|
+
function getPairConfirmRetryAfterMs(lockoutStore, ip) {
|
|
661
|
+
const state2 = getPairConfirmState(lockoutStore, ip);
|
|
662
|
+
const remaining = state2.lockoutUntil - Date.now();
|
|
663
|
+
return remaining > 0 ? remaining : 0;
|
|
664
|
+
}
|
|
665
|
+
function recordPairConfirmFailure(lockoutStore, ip) {
|
|
666
|
+
const state2 = getPairConfirmState(lockoutStore, ip);
|
|
667
|
+
state2.failures += 1;
|
|
668
|
+
if (state2.failures >= PAIR_CONFIRM_MAX_FAILURES) {
|
|
669
|
+
state2.failures = 0;
|
|
670
|
+
state2.lockoutUntil = Date.now() + PAIR_CONFIRM_LOCKOUT_MS;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function clearPairConfirmFailures(lockoutStore, ip) {
|
|
674
|
+
lockoutStore.delete(ip);
|
|
675
|
+
}
|
|
485
676
|
function readAuthToken(req, url) {
|
|
486
677
|
const header = req.headers.authorization;
|
|
487
678
|
if (header && header.startsWith("Bearer ")) {
|
|
@@ -493,26 +684,154 @@ function readAuthToken(req, url) {
|
|
|
493
684
|
if (typeof alt === "string" && alt.length > 0) return alt;
|
|
494
685
|
return null;
|
|
495
686
|
}
|
|
687
|
+
function parseWebSocketProtocols(protocolsHeader) {
|
|
688
|
+
const raw = Array.isArray(protocolsHeader) ? protocolsHeader.join(",") : protocolsHeader ?? "";
|
|
689
|
+
return raw.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
|
690
|
+
}
|
|
691
|
+
function readTokenFromWebSocketProtocols(protocolsHeader) {
|
|
692
|
+
const protocols = parseWebSocketProtocols(protocolsHeader);
|
|
693
|
+
for (const protocol of protocols) {
|
|
694
|
+
if (protocol.startsWith(WS_TOKEN_PROTOCOL_PREFIX)) {
|
|
695
|
+
const token = protocol.slice(WS_TOKEN_PROTOCOL_PREFIX.length).trim();
|
|
696
|
+
if (token.length > 0) return token;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
function readWebSocketAuthToken(req, url) {
|
|
702
|
+
const protocolToken = readTokenFromWebSocketProtocols(
|
|
703
|
+
req.headers["sec-websocket-protocol"]
|
|
704
|
+
);
|
|
705
|
+
if (protocolToken) return protocolToken;
|
|
706
|
+
return readAuthToken(req, url);
|
|
707
|
+
}
|
|
708
|
+
function decrementWsConnectionCount(clientIp) {
|
|
709
|
+
const next = (wsConnectionsByIp.get(clientIp) ?? 1) - 1;
|
|
710
|
+
if (next <= 0) {
|
|
711
|
+
wsConnectionsByIp.delete(clientIp);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
wsConnectionsByIp.set(clientIp, next);
|
|
715
|
+
}
|
|
496
716
|
function sendJson(res, data, status = 200) {
|
|
497
717
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
498
718
|
res.end(JSON.stringify(data));
|
|
499
719
|
}
|
|
720
|
+
function normalizeListenHost(bindHost) {
|
|
721
|
+
if (bindHost === "0.0.0.0") {
|
|
722
|
+
return "127.0.0.1";
|
|
723
|
+
}
|
|
724
|
+
return bindHost;
|
|
725
|
+
}
|
|
726
|
+
async function readJsonBody(req, maxBytes = 8192) {
|
|
727
|
+
const chunks = [];
|
|
728
|
+
let totalBytes = 0;
|
|
729
|
+
for await (const chunk of req) {
|
|
730
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
731
|
+
totalBytes += buffer.byteLength;
|
|
732
|
+
if (totalBytes > maxBytes) {
|
|
733
|
+
return INVALID_JSON_SENTINEL;
|
|
734
|
+
}
|
|
735
|
+
chunks.push(buffer);
|
|
736
|
+
}
|
|
737
|
+
if (chunks.length === 0) {
|
|
738
|
+
return {};
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
742
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
743
|
+
return INVALID_JSON_SENTINEL;
|
|
744
|
+
}
|
|
745
|
+
return parsed;
|
|
746
|
+
} catch {
|
|
747
|
+
return INVALID_JSON_SENTINEL;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function getResponseHost(req, bindHost, port) {
|
|
751
|
+
if (req.headers.host) {
|
|
752
|
+
return req.headers.host;
|
|
753
|
+
}
|
|
754
|
+
return `${normalizeListenHost(bindHost)}:${port}`;
|
|
755
|
+
}
|
|
756
|
+
function splitHostAndPort(rawHost, fallbackPort) {
|
|
757
|
+
try {
|
|
758
|
+
const parsed = new URL(`http://${rawHost}`);
|
|
759
|
+
const parsedPort = parsed.port ? Number.parseInt(parsed.port, 10) : fallbackPort;
|
|
760
|
+
return {
|
|
761
|
+
host: parsed.hostname,
|
|
762
|
+
port: Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort < 65536 ? parsedPort : fallbackPort
|
|
763
|
+
};
|
|
764
|
+
} catch {
|
|
765
|
+
return { host: rawHost, port: fallbackPort };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
500
768
|
function startServer(port = DEFAULT_PORT, options) {
|
|
501
769
|
const stateInstance = options?.state ?? state;
|
|
502
|
-
const tokenPath = options?.tokenPath ?? DEFAULT_TOKEN_PATH;
|
|
503
770
|
const startWatcher = options?.startWatcher ?? true;
|
|
504
771
|
const logBanner = options?.log ?? true;
|
|
505
|
-
|
|
772
|
+
const mobileEnabled = options?.mobile ?? true;
|
|
773
|
+
const bindHost = options?.bindHost ?? (mobileEnabled ? "0.0.0.0" : "127.0.0.1");
|
|
774
|
+
const mobileServiceName = options?.mobileServiceName ?? "Codex Blocker";
|
|
775
|
+
const publishMdns = options?.publishMdns ?? mobileEnabled;
|
|
776
|
+
const mobileQrOutput = options?.mobileQrOutput ?? true;
|
|
777
|
+
const autoStartMobilePairing = options?.autoStartMobilePairing ?? true;
|
|
778
|
+
const mobileInstanceId = createServerInstanceId();
|
|
779
|
+
let authToken = null;
|
|
780
|
+
let activePort = port;
|
|
781
|
+
let mdnsService = null;
|
|
782
|
+
const extensionPairing = mobileEnabled ? options?.extensionPairingManager ?? new ExtensionPairingManager() : null;
|
|
783
|
+
const mobileQrPairing = mobileEnabled ? options?.mobileQrPairingManager ?? new MobileQrPairingManager() : null;
|
|
784
|
+
const printExtensionPairingCode = (code) => {
|
|
785
|
+
if (!logBanner) return;
|
|
786
|
+
console.log(
|
|
787
|
+
`
|
|
788
|
+
[Codex Blocker] Extension pairing code (6-digit, extension only): ${code} (expires in 2 minutes)
|
|
789
|
+
`
|
|
790
|
+
);
|
|
791
|
+
};
|
|
792
|
+
const printPairingQr = (host, portToUse, qrNonce, qrExpiresAt) => {
|
|
793
|
+
if (!logBanner || !mobileQrOutput) return;
|
|
794
|
+
const payload = encodePairingQrPayload({
|
|
795
|
+
host,
|
|
796
|
+
port: portToUse,
|
|
797
|
+
instanceId: mobileInstanceId,
|
|
798
|
+
qrNonce,
|
|
799
|
+
expiresAt: qrExpiresAt
|
|
800
|
+
});
|
|
801
|
+
void renderPairingQr(payload).then((terminalQr) => {
|
|
802
|
+
console.log(
|
|
803
|
+
`[Codex Blocker] Mobile app pairing QR (QR-only, expires in 60 seconds):
|
|
804
|
+
${terminalQr}
|
|
805
|
+
`
|
|
806
|
+
);
|
|
807
|
+
}).catch((error) => {
|
|
808
|
+
console.warn(
|
|
809
|
+
`[Codex Blocker] Failed to render pairing QR: ${error instanceof Error ? error.message : String(error)}`
|
|
810
|
+
);
|
|
811
|
+
});
|
|
812
|
+
};
|
|
813
|
+
const sendPairingToken = (req, res) => {
|
|
814
|
+
if (!authToken) {
|
|
815
|
+
authToken = createAuthToken();
|
|
816
|
+
}
|
|
817
|
+
const host = getResponseHost(req, bindHost, activePort);
|
|
818
|
+
const payload = {
|
|
819
|
+
token: authToken,
|
|
820
|
+
statusUrl: `http://${host}/status`,
|
|
821
|
+
wsUrl: `ws://${host}/ws`
|
|
822
|
+
};
|
|
823
|
+
sendJson(res, payload);
|
|
824
|
+
};
|
|
506
825
|
const server = createServer(async (req, res) => {
|
|
507
826
|
const clientIp = getClientIp(req);
|
|
508
827
|
if (!checkRateLimit(clientIp)) {
|
|
509
828
|
sendJson(res, { error: "Too Many Requests" }, 429);
|
|
510
829
|
return;
|
|
511
830
|
}
|
|
512
|
-
const url = new URL(req.url || "/", `http://localhost:${
|
|
831
|
+
const url = new URL(req.url || "/", `http://localhost:${activePort}`);
|
|
513
832
|
const origin = req.headers.origin;
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
833
|
+
const allowExtensionOrigin = isTrustedChromeExtensionOrigin(origin);
|
|
834
|
+
if (allowExtensionOrigin && origin) {
|
|
516
835
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
517
836
|
res.setHeader("Vary", "Origin");
|
|
518
837
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -522,19 +841,153 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
522
841
|
);
|
|
523
842
|
}
|
|
524
843
|
if (req.method === "OPTIONS") {
|
|
525
|
-
res.writeHead(
|
|
844
|
+
res.writeHead(allowExtensionOrigin ? 204 : 403);
|
|
526
845
|
res.end();
|
|
527
846
|
return;
|
|
528
847
|
}
|
|
848
|
+
if (extensionPairing && mobileQrPairing) {
|
|
849
|
+
if (req.method === "GET" && url.pathname === "/mobile/discovery") {
|
|
850
|
+
const pairingStatus = mobileQrPairing.getStatus();
|
|
851
|
+
const payload = {
|
|
852
|
+
name: mobileServiceName,
|
|
853
|
+
instanceId: mobileInstanceId,
|
|
854
|
+
port: activePort,
|
|
855
|
+
pairingRequired: !authToken,
|
|
856
|
+
pairingExpiresAt: pairingStatus.expiresAt
|
|
857
|
+
};
|
|
858
|
+
sendJson(res, payload);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (req.method === "POST" && url.pathname === "/extension/pair/start") {
|
|
862
|
+
const body = await readJsonBody(req);
|
|
863
|
+
if (body === INVALID_JSON_SENTINEL) {
|
|
864
|
+
sendJson(res, { error: "Invalid JSON" }, 400);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const startBody = body;
|
|
868
|
+
const regenerateCode = startBody.regenerateCode === true;
|
|
869
|
+
const pairingWasActive = extensionPairing.getStatus().active;
|
|
870
|
+
const pairingCode = extensionPairing.startPairing(regenerateCode);
|
|
871
|
+
if (!pairingWasActive || regenerateCode) {
|
|
872
|
+
printExtensionPairingCode(pairingCode.code);
|
|
873
|
+
}
|
|
874
|
+
const payload = {
|
|
875
|
+
expiresAt: pairingCode.expiresAt
|
|
876
|
+
};
|
|
877
|
+
sendJson(res, payload);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (req.method === "POST" && url.pathname === "/extension/pair/confirm") {
|
|
881
|
+
if (!canAttemptPairConfirm(extensionPairConfirmByIp, clientIp)) {
|
|
882
|
+
const retryAfterMs = getPairConfirmRetryAfterMs(extensionPairConfirmByIp, clientIp);
|
|
883
|
+
if (retryAfterMs > 0) {
|
|
884
|
+
res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1e3)));
|
|
885
|
+
}
|
|
886
|
+
sendJson(
|
|
887
|
+
res,
|
|
888
|
+
{
|
|
889
|
+
error: "Too Many Requests",
|
|
890
|
+
code: "pair_confirm_locked",
|
|
891
|
+
retryAfterMs
|
|
892
|
+
},
|
|
893
|
+
429
|
|
894
|
+
);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const body = await readJsonBody(req);
|
|
898
|
+
if (body === INVALID_JSON_SENTINEL) {
|
|
899
|
+
sendJson(res, { error: "Invalid JSON" }, 400);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const confirmBody = body;
|
|
903
|
+
const code = typeof confirmBody.code === "string" ? confirmBody.code.trim() : "";
|
|
904
|
+
if (code.length === 0) {
|
|
905
|
+
sendJson(res, { error: "Provide extension pairing code" }, 400);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const confirmed = extensionPairing.confirmPairingCode(code);
|
|
909
|
+
if (!confirmed) {
|
|
910
|
+
recordPairConfirmFailure(extensionPairConfirmByIp, clientIp);
|
|
911
|
+
sendJson(res, { error: "Invalid or expired extension pairing code" }, 401);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
clearPairConfirmFailures(extensionPairConfirmByIp, clientIp);
|
|
915
|
+
sendPairingToken(req, res);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (req.method === "POST" && url.pathname === "/mobile/pair/start") {
|
|
919
|
+
const body = await readJsonBody(req);
|
|
920
|
+
if (body === INVALID_JSON_SENTINEL) {
|
|
921
|
+
sendJson(res, { error: "Invalid JSON" }, 400);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const startBody = body;
|
|
925
|
+
const refreshQr = startBody.refreshQr === true;
|
|
926
|
+
const pairingWasActive = mobileQrPairing.getStatus().active;
|
|
927
|
+
const pairingCode = mobileQrPairing.startPairing(refreshQr);
|
|
928
|
+
if (!pairingWasActive || refreshQr) {
|
|
929
|
+
const rawHost = getResponseHost(req, bindHost, activePort);
|
|
930
|
+
const hostInfo = splitHostAndPort(rawHost, activePort);
|
|
931
|
+
printPairingQr(
|
|
932
|
+
hostInfo.host,
|
|
933
|
+
hostInfo.port,
|
|
934
|
+
pairingCode.qrNonce,
|
|
935
|
+
pairingCode.qrExpiresAt
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
const payload = {
|
|
939
|
+
expiresAt: pairingCode.expiresAt,
|
|
940
|
+
qrExpiresAt: pairingCode.qrExpiresAt,
|
|
941
|
+
qrFormat: PAIRING_QR_FORMAT
|
|
942
|
+
};
|
|
943
|
+
sendJson(res, payload);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (req.method === "POST" && url.pathname === "/mobile/pair/confirm") {
|
|
947
|
+
if (!canAttemptPairConfirm(mobilePairConfirmByIp, clientIp)) {
|
|
948
|
+
const retryAfterMs = getPairConfirmRetryAfterMs(mobilePairConfirmByIp, clientIp);
|
|
949
|
+
if (retryAfterMs > 0) {
|
|
950
|
+
res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1e3)));
|
|
951
|
+
}
|
|
952
|
+
sendJson(
|
|
953
|
+
res,
|
|
954
|
+
{
|
|
955
|
+
error: "Too Many Requests",
|
|
956
|
+
code: "pair_confirm_locked",
|
|
957
|
+
retryAfterMs
|
|
958
|
+
},
|
|
959
|
+
429
|
|
960
|
+
);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const body = await readJsonBody(req);
|
|
964
|
+
if (body === INVALID_JSON_SENTINEL) {
|
|
965
|
+
sendJson(res, { error: "Invalid JSON" }, 400);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const confirmBody = body;
|
|
969
|
+
const qrNonce = typeof confirmBody.qrNonce === "string" ? confirmBody.qrNonce.trim() : "";
|
|
970
|
+
if (qrNonce.length === 0) {
|
|
971
|
+
sendJson(res, { error: "Provide mobile QR nonce" }, 400);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const confirmed = mobileQrPairing.confirmPairingQrNonce(qrNonce);
|
|
975
|
+
if (!confirmed) {
|
|
976
|
+
recordPairConfirmFailure(mobilePairConfirmByIp, clientIp);
|
|
977
|
+
sendJson(res, { error: "Invalid or expired QR nonce" }, 401);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
clearPairConfirmFailures(mobilePairConfirmByIp, clientIp);
|
|
981
|
+
sendPairingToken(req, res);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
529
985
|
const providedToken = readAuthToken(req, url);
|
|
530
986
|
if (authToken) {
|
|
531
987
|
if (!providedToken || providedToken !== authToken) {
|
|
532
988
|
sendJson(res, { error: "Unauthorized" }, 401);
|
|
533
989
|
return;
|
|
534
990
|
}
|
|
535
|
-
} else if (providedToken && allowOrigin) {
|
|
536
|
-
authToken = providedToken;
|
|
537
|
-
saveToken(tokenPath, providedToken);
|
|
538
991
|
} else {
|
|
539
992
|
sendJson(res, { error: "Unauthorized" }, 401);
|
|
540
993
|
return;
|
|
@@ -547,11 +1000,10 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
547
1000
|
});
|
|
548
1001
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
549
1002
|
wss.on("connection", (ws, req) => {
|
|
550
|
-
const wsUrl = new URL(req.url || "", `http://localhost:${
|
|
551
|
-
const providedToken = wsUrl
|
|
552
|
-
const origin = req.headers.origin;
|
|
553
|
-
const allowOrigin = isChromeExtensionOrigin(origin);
|
|
1003
|
+
const wsUrl = new URL(req.url || "", `http://localhost:${activePort}`);
|
|
1004
|
+
const providedToken = readWebSocketAuthToken(req, wsUrl);
|
|
554
1005
|
const clientIp = getClientIp(req);
|
|
1006
|
+
const allowExtensionOrigin = isTrustedChromeExtensionOrigin(req.headers.origin);
|
|
555
1007
|
const currentConnections = wsConnectionsByIp.get(clientIp) ?? 0;
|
|
556
1008
|
if (currentConnections >= MAX_WS_CONNECTIONS_PER_IP) {
|
|
557
1009
|
ws.close(1013, "Too many connections");
|
|
@@ -562,9 +1014,8 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
562
1014
|
ws.close(1008, "Unauthorized");
|
|
563
1015
|
return;
|
|
564
1016
|
}
|
|
565
|
-
} else if (providedToken
|
|
1017
|
+
} else if (canBootstrapExtensionToken(providedToken, allowExtensionOrigin)) {
|
|
566
1018
|
authToken = providedToken;
|
|
567
|
-
saveToken(tokenPath, providedToken);
|
|
568
1019
|
} else {
|
|
569
1020
|
ws.close(1008, "Unauthorized");
|
|
570
1021
|
return;
|
|
@@ -586,17 +1037,11 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
586
1037
|
});
|
|
587
1038
|
ws.on("close", () => {
|
|
588
1039
|
unsubscribe();
|
|
589
|
-
|
|
590
|
-
clientIp,
|
|
591
|
-
Math.max(0, (wsConnectionsByIp.get(clientIp) ?? 1) - 1)
|
|
592
|
-
);
|
|
1040
|
+
decrementWsConnectionCount(clientIp);
|
|
593
1041
|
});
|
|
594
1042
|
ws.on("error", () => {
|
|
595
1043
|
unsubscribe();
|
|
596
|
-
|
|
597
|
-
clientIp,
|
|
598
|
-
Math.max(0, (wsConnectionsByIp.get(clientIp) ?? 1) - 1)
|
|
599
|
-
);
|
|
1044
|
+
decrementWsConnectionCount(clientIp);
|
|
600
1045
|
});
|
|
601
1046
|
});
|
|
602
1047
|
const codexWatcher = new CodexSessionWatcher(stateInstance, {
|
|
@@ -616,23 +1061,62 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
616
1061
|
close: async () => {
|
|
617
1062
|
stateInstance.destroy();
|
|
618
1063
|
codexWatcher.stop();
|
|
1064
|
+
if (mdnsService) {
|
|
1065
|
+
await mdnsService.stop();
|
|
1066
|
+
mdnsService = null;
|
|
1067
|
+
}
|
|
619
1068
|
await new Promise((resolve) => wss.close(() => resolve()));
|
|
620
1069
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
621
1070
|
}
|
|
622
1071
|
};
|
|
623
|
-
server.listen(port,
|
|
1072
|
+
server.listen(port, bindHost, () => {
|
|
624
1073
|
const address = server.address();
|
|
625
1074
|
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
626
1075
|
handle.port = actualPort;
|
|
1076
|
+
activePort = actualPort;
|
|
627
1077
|
resolveReady(actualPort);
|
|
1078
|
+
if (extensionPairing && mobileQrPairing && autoStartMobilePairing) {
|
|
1079
|
+
if (logBanner) {
|
|
1080
|
+
const pairingSummary = mobileQrOutput ? "extension uses 6-digit code only; mobile app uses QR only." : "extension uses 6-digit code only.";
|
|
1081
|
+
console.log(`[Codex Blocker] Pairing paths: ${pairingSummary}`);
|
|
1082
|
+
}
|
|
1083
|
+
const extensionPairingCode = extensionPairing.startPairing();
|
|
1084
|
+
printExtensionPairingCode(extensionPairingCode.code);
|
|
1085
|
+
const mobilePairingCode = mobileQrPairing.startPairing();
|
|
1086
|
+
printPairingQr(
|
|
1087
|
+
"codex-blocker.local",
|
|
1088
|
+
actualPort,
|
|
1089
|
+
mobilePairingCode.qrNonce,
|
|
1090
|
+
mobilePairingCode.qrExpiresAt
|
|
1091
|
+
);
|
|
1092
|
+
if (publishMdns) {
|
|
1093
|
+
try {
|
|
1094
|
+
mdnsService = publishMobileService({
|
|
1095
|
+
name: mobileServiceName,
|
|
1096
|
+
type: MOBILE_SERVICE_TYPE,
|
|
1097
|
+
port: actualPort,
|
|
1098
|
+
instanceId: mobileInstanceId
|
|
1099
|
+
});
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (logBanner) {
|
|
1102
|
+
console.warn(
|
|
1103
|
+
`[Codex Blocker] Failed to publish mDNS service: ${error instanceof Error ? error.message : String(error)}`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
628
1109
|
if (!logBanner) return;
|
|
1110
|
+
const displayHost = bindHost === "0.0.0.0" ? "localhost" : bindHost;
|
|
1111
|
+
const mobileLine = !mobileEnabled ? "" : mobileQrOutput ? `
|
|
1112
|
+
\u2502 Mobile: enabled (${mobileServiceName}) \u2502` : "\n\u2502 Mobile: extension-only \u2502";
|
|
629
1113
|
console.log(`
|
|
630
1114
|
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
631
1115
|
\u2502 \u2502
|
|
632
1116
|
\u2502 Codex Blocker Server \u2502
|
|
633
1117
|
\u2502 \u2502
|
|
634
|
-
\u2502 HTTP: http
|
|
635
|
-
\u2502 WebSocket: ws
|
|
1118
|
+
\u2502 HTTP: http://${displayHost}:${actualPort} \u2502
|
|
1119
|
+
\u2502 WebSocket: ws://${displayHost}:${actualPort}/ws \u2502${mobileLine}
|
|
636
1120
|
\u2502 \u2502
|
|
637
1121
|
\u2502 Watching Codex sessions... \u2502
|
|
638
1122
|
\u2502 \u2502
|
|
@@ -650,5 +1134,7 @@ function startServer(port = DEFAULT_PORT, options) {
|
|
|
650
1134
|
|
|
651
1135
|
export {
|
|
652
1136
|
DEFAULT_PORT,
|
|
1137
|
+
isTrustedChromeExtensionOrigin,
|
|
1138
|
+
isLoopbackClientIp,
|
|
653
1139
|
startServer
|
|
654
1140
|
};
|
package/dist/server.d.ts
CHANGED
|
@@ -38,12 +38,53 @@ declare class SessionState {
|
|
|
38
38
|
destroy(): void;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
type PairingStatus = {
|
|
42
|
+
active: boolean;
|
|
43
|
+
expiresAt: number | null;
|
|
44
|
+
};
|
|
45
|
+
type ExtensionPairingCode = {
|
|
46
|
+
code: string;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
};
|
|
49
|
+
declare class ExtensionPairingManager {
|
|
50
|
+
private readonly now;
|
|
51
|
+
private pairing;
|
|
52
|
+
constructor(now?: () => number);
|
|
53
|
+
startPairing(regenerateCode?: boolean): ExtensionPairingCode;
|
|
54
|
+
getStatus(): PairingStatus;
|
|
55
|
+
confirmPairingCode(code: string): boolean;
|
|
56
|
+
private expireIfNeeded;
|
|
57
|
+
}
|
|
58
|
+
type MobileQrPairingCode = {
|
|
59
|
+
expiresAt: number;
|
|
60
|
+
qrNonce: string;
|
|
61
|
+
qrExpiresAt: number;
|
|
62
|
+
};
|
|
63
|
+
declare class MobileQrPairingManager {
|
|
64
|
+
private readonly now;
|
|
65
|
+
private pairing;
|
|
66
|
+
constructor(now?: () => number);
|
|
67
|
+
startPairing(refreshQr?: boolean): MobileQrPairingCode;
|
|
68
|
+
getStatus(): PairingStatus;
|
|
69
|
+
confirmPairingQrNonce(qrNonce: string): boolean;
|
|
70
|
+
private expireIfNeeded;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare function isTrustedChromeExtensionOrigin(origin?: string | null): boolean;
|
|
74
|
+
declare function isLoopbackClientIp(clientIp?: string | null): boolean;
|
|
41
75
|
type ServerOptions = {
|
|
42
76
|
sessionsDir?: string;
|
|
43
77
|
startWatcher?: boolean;
|
|
44
|
-
tokenPath?: string;
|
|
45
78
|
state?: SessionState;
|
|
46
79
|
log?: boolean;
|
|
80
|
+
bindHost?: string;
|
|
81
|
+
mobile?: boolean;
|
|
82
|
+
mobileServiceName?: string;
|
|
83
|
+
publishMdns?: boolean;
|
|
84
|
+
extensionPairingManager?: ExtensionPairingManager;
|
|
85
|
+
mobileQrPairingManager?: MobileQrPairingManager;
|
|
86
|
+
mobileQrOutput?: boolean;
|
|
87
|
+
autoStartMobilePairing?: boolean;
|
|
47
88
|
};
|
|
48
89
|
type ServerHandle = {
|
|
49
90
|
port: number;
|
|
@@ -52,4 +93,4 @@ type ServerHandle = {
|
|
|
52
93
|
};
|
|
53
94
|
declare function startServer(port?: number, options?: ServerOptions): ServerHandle;
|
|
54
95
|
|
|
55
|
-
export { type ServerHandle, type ServerOptions, startServer };
|
|
96
|
+
export { type ServerHandle, type ServerOptions, isLoopbackClientIp, isTrustedChromeExtensionOrigin, startServer };
|
package/dist/server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-blocker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Automatically blocks distracting websites unless Codex is actively running. Forked from Theo Browne's (T3) Claude Blocker",
|
|
5
5
|
"author": "Adam Blumoff ",
|
|
6
6
|
"repository": {
|
|
@@ -26,10 +26,13 @@
|
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"bonjour-service": "^1.3.0",
|
|
30
|
+
"qrcode": "^1.5.4",
|
|
29
31
|
"ws": "^8.18.0"
|
|
30
32
|
},
|
|
31
33
|
"devDependencies": {
|
|
32
34
|
"@types/node": "^22.10.2",
|
|
35
|
+
"@types/qrcode": "^1.5.5",
|
|
33
36
|
"@types/ws": "^8.5.13",
|
|
34
37
|
"tsup": "^8.3.5",
|
|
35
38
|
"tsx": "^4.19.2",
|