Files

1333 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// voice_cdp.js —— ChatGPT/Codex 语音输入注入:主进程侧。
// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 ChatGPT/Codex 桌面版,
// 往其页面注入麦克风按钮脚本,录音经 binding 回传 → 调中转站 Whisper 转写 → 文字回填 composer。
// 不改 Codex 源码、不写补丁文件。无第三方依赖:CDP over WebSocket 用 Node 内置 net 手写最小 WS 客户端
// (Electron 34 主进程的 Node 无全局 WebSocket);HTTP 用全局 fetch;multipart 用全局 FormData/Blob。
//
// 机制参考 CodexPlusPlus 的 bridge.rs:Runtime.evaluate / Page.addScriptToEvaluateOnNewDocument /
// Runtime.addBinding + Runtime.bindingCalled。
const { execFile, spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
const router = require("./router");
const codexDesktop = require("./codex_desktop");
const globalDictation = require("./global_dictation");
const { INJECT_SCRIPT } = require("./voice_inject");
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
const tokenUsage = require("./token_usage");
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
const READY_POLL_MAX_MS = 180000; // 轮询就绪最长等待:Windows 冷启动可能要 2~3 分钟
const PACKAGED_READY_POLL_MAX_MS = 180000; // Store/MSIX 版 AppID 启动后也要给足白屏加载时间
const WINDOWS_LAUNCH_TIMEOUT_MS = 4500;
const WINDOWS_APPID_ACTIVATE_TIMEOUT_MS = 10000;
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
// —— 状态机 ——
// unknown / checking / not_running / running_no_port / restarting / waiting_debug_port /
// unsupported_packaged / connecting / injecting / connected / disconnected / error
let state = {
phase: "unknown",
port: DEFAULT_PORT,
codexPath: "",
codexPackaged: false,
codexLaunchType: "exe",
codexAppId: "",
launchWarning: "",
portDiagnostic: "",
lastError: "",
discovery: null,
target: null, // { id, url, title }
};
let session = null; // 当前 CdpSession
let voiceScriptIdentifier = ""; // 用于断开时撤销后续页面的语音自动注入
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
let networkAttachGeneration = 0;
let onStatusCb = null;
let onSessionDeleteCb = null;
let tokenSyncTimer = null;
function snapshot() {
const { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery } = state;
return { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery };
}
function setPhase(phase, extra = {}) {
state = { ...state, phase, ...extra };
if (onStatusCb) {
try {
onStatusCb(snapshot());
} catch {}
}
}
async function syncTokenUsage() {
if (!session) return;
const usage = await tokenUsage.readDaily();
if (!usage || !usage.available) return;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage) + ")",
awaitPromise: false,
});
} catch {}
}
function startTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = setInterval(() => { syncTokenUsage().catch(() => {}); }, 2500);
syncTokenUsage().catch(() => {});
}
function stopTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = null;
}
// —— 探测 Codex 可执行路径 ——
function scanCodexExecutable() {
const candidates = codexDesktop.desktopExecutableCandidates();
const candidate = candidates.find((item) => item && item.path && !item.packaged) ||
candidates.find((item) => item && item.path && item.packaged);
const exe = candidate && candidate.path ? candidate.path : "";
state.codexPath = exe;
state.codexPackaged = !!(candidate && candidate.packaged);
state.codexLaunchType = (candidate && candidate.launchType) || "exe";
state.codexAppId = (candidate && candidate.appId) || "";
state.discovery = codexDesktop.discoveryReport ? codexDesktop.discoveryReport() : null;
return exe;
}
function desktopNotFoundError() {
const summary = codexDesktop.discoverySummary ? codexDesktop.discoverySummary() : "";
const discovery = state.discovery || {};
const hasPackaged = Array.isArray(discovery.candidates) && discovery.candidates.some((item) => item && item.packaged);
if (process.platform === "win32") {
return "未找到可直接启动的 ChatGPT/Codex 桌面程序。" +
(summary ? "扫描结果:" + summary + "。" : "") +
(hasPackaged ? "检测到 WindowsApps/Store 受保护入口,但它不能直接附加调试端口。" : "") +
"请安装官网下载的桌面版后重试。";
}
return "未找到 ChatGPT/Codex 桌面程序。" + (summary ? "扫描结果:" + summary + "。" : "");
}
function desktopLaunchError(exePath, err) {
const msg = String((err && err.message) || err || "");
if (process.platform === "win32" && /eperm/i.test(msg)) {
return "Windows 拒绝启动 ChatGPT/Codex 调试模式(EPERM)。路径:" + exePath +
"。如果这是 Microsoft Store/WindowsApps 版本,请改装官网下载的桌面版;如果是普通 exe,请用管理员权限启动工具箱后再试。";
}
return "启动 ChatGPT/Codex 失败:" + msg + (exePath ? "。路径:" + exePath : "");
}
function compactError(err, fallback = "未知错误") {
const parts = [
err && err.stderr,
err && err.stdout,
err && err.message,
err,
].filter(Boolean).map((v) => String(v));
const raw = parts.find((v) => v.trim()) || fallback;
return raw
.replace(/Command failed:[\s\S]*?(?=(\r?\n|$))/i, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 220) || fallback;
}
function compactCommandLine(cmd) {
const text = String(cmd || "").replace(/\s+/g, " ").trim();
if (!text) return "";
const hasWindowsApps = /WindowsApps/i.test(text);
const hasDebugArg = /--remote-debugging-port/i.test(text);
const exe = (text.match(/(?:^|[ "])([A-Z]:\\[^"]*?(?:ChatGPT|Codex)[^"\\]*\.exe)/i) || [])[1] || "";
const pidPart = exe ? exe.replace(/^.*[\\/]/, "") : "";
const tags = [];
if (hasWindowsApps) tags.push("WindowsApps");
tags.push(hasDebugArg ? "含调试参数" : "无调试参数");
return [pidPart, tags.join("/")].filter(Boolean).join(" ");
}
function updateWindowsProcessDiagnostic(port) {
if (process.platform !== "win32" || !codexDesktop.windowsProcessDiagnostics) return null;
try {
const d = codexDesktop.windowsProcessDiagnostics(port);
if (!d || !Array.isArray(d.rows) || !d.rows.length) {
state.portDiagnostic = "未检测到 ChatGPT/Codex 进程";
return d;
}
state.portDiagnostic = d.hasDebugArg
? "进程命令行已包含调试端口参数"
: "进程命令行未包含 --remote-debugging-port=" + port + ",调试端口不会打开";
const rowsSummary = d.rows.map((row) => {
const name = (row && row.Name) || "process";
const pid = (row && row.ProcessId) || "?";
const cmd = compactCommandLine((row && row.CommandLine) || "");
return `${name}#${pid}${cmd ? " " + cmd : ""}`;
}).join(" | ");
if (rowsSummary) state.portDiagnostic += ";" + rowsSummary;
state.portDiagnostic = state.portDiagnostic.slice(0, 260);
return d;
} catch (e) {
state.portDiagnostic = "读取进程命令行失败:" + compactError(e);
return null;
}
}
function debugPortFailureMessage(action, port) {
const diag = updateWindowsProcessDiagnostic(port);
if (state.codexPackaged) {
const reason = diag && diag.rows && diag.rows.length && !diag.hasDebugArg
? "当前 Store/MSIX 启动参数没有进入 Codex 进程,无法打开调试端口。"
: "当前 Store/MSIX 启动方式未开放调试端口。";
return "已尝试通过 Windows AppID " + action + " ChatGPT/Codex,但未检测到调试端口。" +
reason + "建议改装官网下载桌面版。";
}
return action === "重启" ? "重启 ChatGPT 后未开放调试端口" : "Codex 启动后未开放调试端口,请重试";
}
function isPackagedDebugArgMissing(port) {
if (!state.codexPackaged || process.platform !== "win32") return false;
const d = updateWindowsProcessDiagnostic(port);
return !!(d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg);
}
function setDebugPortFailure(action, port) {
const unsupported = isPackagedDebugArgMissing(port);
setPhase(unsupported ? "unsupported_packaged" : "error", {
lastError: debugPortFailureMessage(action, port) + (state.launchWarning ? "(" + state.launchWarning + ")" : ""),
portDiagnostic: state.portDiagnostic,
});
}
function windowsLaunchArgs(port) {
return [
`--remote-debugging-port=${port}`,
`--remote-allow-origins=http://127.0.0.1:${port}`,
];
}
function quoteWindowsArgument(arg) {
const text = String(arg || "");
if (text && !/[ \t"]/.test(text)) return text;
let out = "\"";
let backslashes = 0;
for (const ch of text) {
if (ch === "\\") {
backslashes += 1;
continue;
}
if (ch === "\"") {
out += "\\".repeat(backslashes * 2 + 1) + "\"";
backslashes = 0;
continue;
}
out += "\\".repeat(backslashes) + ch;
backslashes = 0;
}
out += "\\".repeat(backslashes * 2) + "\"";
return out;
}
function windowsCommandLineArguments(args) {
return args.map(quoteWindowsArgument).join(" ");
}
function windowsActivateAppId(appId, port) {
return new Promise((resolve, reject) => {
if (process.platform !== "win32") {
reject(new Error("AppID activation only supports Windows"));
return;
}
const script = [
"$ErrorActionPreference='Stop'",
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8",
"$OutputEncoding=[System.Text.Encoding]::UTF8",
"$code = @'",
"using System;",
"using System.Runtime.InteropServices;",
"namespace TdCodexAppActivation {",
"[ComImport, Guid(\"45BA127D-10A8-46EA-8AB7-56EA9078943C\")]",
"public class ApplicationActivationManager {}",
"[ComImport, Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
"public interface IApplicationActivationManager {",
" [PreserveSig]",
" int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, [MarshalAs(UnmanagedType.LPWStr)] string arguments, int options, out uint processId);",
" [PreserveSig]",
" int ActivateForFile([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, [MarshalAs(UnmanagedType.LPWStr)] string verb, out uint processId);",
" [PreserveSig]",
" int ActivateForProtocol([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, out uint processId);",
"}",
"public static class Launcher {",
" const int COINIT_APARTMENTTHREADED = 0x2;",
" const int RPC_E_CHANGED_MODE = unchecked((int)0x80010106);",
" [DllImport(\"ole32.dll\")] static extern int CoInitializeEx(IntPtr pvReserved, int dwCoInit);",
" [DllImport(\"ole32.dll\")] static extern void CoUninitialize();",
" public static uint Activate(string appUserModelId, string arguments) {",
" int coinit = CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);",
" bool shouldUninitialize = coinit >= 0;",
" if (coinit < 0 && coinit != RPC_E_CHANGED_MODE) Marshal.ThrowExceptionForHR(coinit);",
" try {",
" var manager = (IApplicationActivationManager)new ApplicationActivationManager();",
" uint processId = 0;",
" int hr = manager.ActivateApplication(appUserModelId, arguments, 0, out processId);",
" if (hr != 0) throw new COMException(\"ActivateApplication failed\", hr);",
" return processId;",
" } finally {",
" if (shouldUninitialize) CoUninitialize();",
" }",
" }",
"}",
"}",
"'@",
"Add-Type -TypeDefinition $code -ErrorAction Stop",
"$activatedProcessId=[TdCodexAppActivation.Launcher]::Activate($env:TD_CODEX_APPID, $env:TD_CODEX_LAUNCH_ARGS)",
"[pscustomobject]@{ok=$true;pid=$activatedProcessId;args=$env:TD_CODEX_LAUNCH_ARGS}|ConvertTo-Json -Compress",
].join("\n");
const scriptPath = path.join(os.tmpdir(), "td-codex-appid-activate-" + process.pid + "-" + Date.now() + ".ps1");
try {
fs.writeFileSync(scriptPath, script, "utf8");
} catch (e) {
reject(e);
return;
}
execFile("powershell.exe", ["-NoProfile", "-Sta", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
env: {
...process.env,
TD_CODEX_APPID: String(appId || ""),
TD_CODEX_LAUNCH_PORT: String(port),
TD_CODEX_LAUNCH_ARGS: windowsCommandLineArguments(windowsLaunchArgs(port)),
},
windowsHide: true,
timeout: WINDOWS_APPID_ACTIVATE_TIMEOUT_MS,
}, (err, stdout) => {
try { fs.rmSync(scriptPath, { force: true }); } catch {}
if (err) {
reject(err);
return;
}
let data = null;
try {
data = JSON.parse(String(stdout || "").trim());
} catch {}
resolve(data || { ok: true });
});
});
}
function windowsShellStart(exePath, port) {
return new Promise((resolve, reject) => {
if (process.platform !== "win32") {
reject(new Error("shell start only supports Windows"));
return;
}
const launchType = state.codexLaunchType || "exe";
const target = launchType === "appId"
? "shell:AppsFolder\\" + String(state.codexAppId || exePath || "").trim()
: String(exePath || "").replace(/^\\\\\?\\/, "");
if (launchType === "appId") {
windowsActivateAppId(state.codexAppId || exePath, port).then((data) => {
const pid = data && data.pid ? `,PID ${data.pid}` : "";
state.launchWarning = "已通过 Windows AppID 激活 Store/MSIX 版 Codex" + pid;
resolve(true);
}).catch((activateErr) => {
execFile("explorer.exe", [target], { windowsHide: true, timeout: WINDOWS_LAUNCH_TIMEOUT_MS }, (err) => {
const first = "COM 激活提示:" + compactError(activateErr, "AppID 激活失败");
if (err) {
// explorer.exe often returns a failure code for shell:AppsFolder
// even after Windows has already handed off and launched the app.
const second = "explorer 提示:" + compactError(err, "explorer 启动失败");
state.launchWarning = (first + ";" + second).slice(0, 220);
resolve(false);
return;
}
state.launchWarning = (first + ";已通过 explorer 兜底拉起 Store/MSIX 版 Codex").slice(0, 220);
resolve(true);
});
});
return;
}
const script = [
"$target=$env:TD_CODEX_LAUNCH_TARGET",
"$args=@('--remote-debugging-port=' + $env:TD_CODEX_LAUNCH_PORT, '--remote-allow-origins=http://127.0.0.1:' + $env:TD_CODEX_LAUNCH_PORT)",
"Start-Process -FilePath $target -ArgumentList $args",
].join(";");
execFile("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
env: {
...process.env,
TD_CODEX_LAUNCH_TARGET: target,
TD_CODEX_LAUNCH_PORT: String(port),
},
windowsHide: true,
timeout: WINDOWS_LAUNCH_TIMEOUT_MS,
}, (err) => {
if (err) {
reject(err);
return;
}
state.launchWarning = "";
resolve(true);
});
});
}
// —— HTTP:GET /json 取 targets ——
async function listTargets(port) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), CDP_HTTP_TIMEOUT);
try {
const res = await fetch(`http://127.0.0.1:${port}/json`, { signal: ctrl.signal });
if (!res.ok) return [];
const arr = await res.json();
return Array.isArray(arr) ? arr : [];
} catch {
return [];
} finally {
clearTimeout(t);
}
}
// —— 挑可注入的 Codex 主页面 target ——
// Codex 可能有多个 page:主对话页 app://-/index.html,以及 avatar-overlay / global-dictation
// 等带 ?initialRoute= 的子路由覆盖层。优先选「无 query 的主入口」,次选普通 codex 页,排除覆盖层。
function pickTarget(targets) {
let main = null; // url 恰为 app://-/index.html(无 query)
let fallback = null; // 其它非覆盖层 codex 页
for (const t of targets) {
if (t.type !== "page") continue;
const ws = t.webSocketDebuggerUrl;
if (!ws) continue;
const url = (t.url || "").toLowerCase();
const title = (t.title || "").toLowerCase();
// 覆盖层:带 initialRoute 的子路由(avatar-overlay / global-dictation 等)
const isOverlayRoute = url.includes("initialroute=");
const isCodex =
url.startsWith("app://-/index.html") || url.includes("index.html") || title.includes("codex");
if (!isCodex) continue;
if (isOverlayRoute) continue; // 覆盖层不注入
if (url === "app://-/index.html" || url === "app://-/index.html/") {
main = main || t;
} else {
fallback = fallback || t;
}
}
return main || fallback;
}
// —— 最小 WebSocket 客户端(Node 内置 net,无需依赖;仅支持 CDP 需要的文本帧)——
// 解析 ws://host:port/path,TCP 连接 → HTTP 升级握手(带 Sec-WebSocket-Key)→ 文本帧收发。
// 客户端发送必须掩码;CDP 服务端发送不掩码。帧可能分片到达,用缓冲区拼。
function wsConnect(wsUrl) {
const u = new URL(wsUrl);
const host = u.hostname;
const port = u.port || (u.protocol === "wss:" ? "443" : "80");
const pathStr = u.pathname + u.search;
const key = crypto.randomBytes(16).toString("base64");
const socket = net.connect({ host, port: Number(port) });
const events = { open: [], message: [], close: [], error: [] };
const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg));
socket.setTimeout(CDP_WS_TIMEOUT, () => {
if (!handshakeDone) emit("error", new Error("WS 连接超时"));
try { socket.destroy(); } catch {}
});
// —— 发送一帧文本(客户端必须掩码)——
function sendFrame(str) {
const payload = Buffer.from(str, "utf8");
const len = payload.length;
// opcode 1 = 文本;FIN=1
let header;
const mask = crypto.randomBytes(4);
if (len < 126) {
header = Buffer.alloc(6);
header[0] = 0x81;
header[1] = 0x80 | len;
mask.copy(header, 2);
} else if (len < 65536) {
header = Buffer.alloc(8);
header[0] = 0x81;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
mask.copy(header, 4);
} else {
header = Buffer.alloc(14);
header[0] = 0x81;
header[1] = 0x80 | 127;
header.writeUInt32BE(0, 2);
header.writeUInt32BE(len, 6);
mask.copy(header, 10);
}
const masked = Buffer.alloc(len);
for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i & 3];
socket.write(Buffer.concat([header, masked]));
}
// —— 接收缓冲 + 帧解析 ——
let buf = Buffer.alloc(0);
let handshakeDone = false;
function parseFrames() {
while (true) {
if (buf.length < 2) return;
const b0 = buf[0];
const b1 = buf[1];
const opcode = b0 & 0x0f;
const masked = (b1 & 0x80) !== 0;
let payloadLen = b1 & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buf.length < 4) return;
payloadLen = buf.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buf.length < 10) return;
// 高 32 位忽略(CDP 帧不会那么大)
payloadLen = Number(buf.readUInt32BE(6));
offset = 10;
}
let maskKey = null;
if (masked) {
if (buf.length < offset + 4) return;
maskKey = buf.slice(offset, offset + 4);
offset += 4;
}
if (buf.length < offset + payloadLen) return;
let payload = buf.slice(offset, offset + payloadLen);
if (masked) {
const un = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i++) un[i] = payload[i] ^ maskKey[i & 3];
payload = un;
}
buf = buf.slice(offset + payloadLen);
// 只处理文本(1)/ping(9)/pong(10)/关闭(8)
if (opcode === 0x8) {
// 关闭帧
emit("close");
try {
socket.end();
} catch {}
return;
} else if (opcode === 0x9) {
// ping → 回 pong(无掩码服务端不要求,但回时按服务端→客户端本可不掩码;此处回文本帧不规范,CDP 不发 ping)
continue;
} else if (opcode === 0xa) {
continue; // pong,忽略
} else {
// 文本帧(含分片首帧 opcode 0 也按文本拼;CDP 单帧文本足够,简单按单帧处理)
emit("message", payload.toString("utf8"));
}
}
}
socket.on("connect", () => {
const req =
`GET ${pathStr} HTTP/1.1\r\n` +
`Host: ${host}:${port}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`;
socket.write(req);
});
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
if (!handshakeDone) {
const idx = buf.indexOf("\r\n\r\n");
if (idx < 0) return;
const head = buf.slice(0, idx).toString("utf8");
buf = buf.slice(idx + 4);
if (!/101/.test(head.split("\r\n")[0])) {
emit("error", new Error("WS 握手失败: " + head.split("\r\n")[0]));
try {
socket.destroy();
} catch {}
return;
}
handshakeDone = true;
try { socket.setTimeout(0); } catch {}
emit("open");
parseFrames();
} else {
parseFrames();
}
});
socket.on("close", () => emit("close"));
socket.on("error", (e) => emit("error", e));
return {
send: sendFrame,
close: () => {
try {
socket.destroy();
} catch {}
},
on: (name, cb) => {
(events[name] = events[name] || []).push(cb);
},
_socket: socket,
};
}
// —— CDP WebSocket 会话 ——
function openSession(wsUrl, onBindingCalled) {
const pending = new Map(); // id → {resolve, reject}
const handlers = new Map(); // method → Set<cb>
let nextId = 1;
let closed = false;
const ws = wsConnect(wsUrl);
function send(method, params = {}) {
return new Promise((resolve, reject) => {
if (closed) return reject(new Error("CDP 会话已关闭"));
const id = nextId++;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("CDP 命令超时: " + method));
}, CDP_CMD_TIMEOUT);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
ws.send(JSON.stringify({ id, method, params }));
});
}
function on(method, cb) {
if (!handlers.has(method)) handlers.set(method, new Set());
handlers.get(method).add(cb);
}
function close() {
closed = true;
try {
ws.close();
} catch {}
}
ws.on("message", (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
if (msg.id != null) {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.error) p.reject(new Error(msg.error.message || "CDP error"));
else p.resolve(msg.result);
return;
}
if (msg.method) {
if (msg.method === "Runtime.bindingCalled" && onBindingCalled) {
try {
onBindingCalled(msg.params);
} catch {}
}
const set = handlers.get(msg.method);
if (set) for (const cb of set) cb(msg.params);
}
});
ws.on("close", () => {
closed = true;
for (const { reject } of pending.values()) {
try {
reject(new Error("CDP 会话关闭"));
} catch {}
}
pending.clear();
});
return new Promise((resolve, reject) => {
ws.on("open", () => resolve({ send, on, close, ws }));
ws.on("error", (e) => reject(new Error("CDP 连接失败:" + (e && e.message ? e.message : "ws error"))));
});
}
// —— 转写:中转站 /v1/audio/transcriptions(用 sttGroup:勾选了 STT 的分组)——
async function runStt(audioB64, mime) {
const g = router.sttGroup();
if (!g || !g.baseUrl || !g.key) throw new Error("未配置支持语音转文字的中转分组(在路由中转里勾选「支持语音转文字」)");
const base = (g.baseUrl || "").replace(/\/+$/, "");
const model = g.sttModel || "whisper-1";
const buf = Buffer.from(audioB64, "base64");
const ext = mime && mime.includes("webm") ? "webm" : mime && mime.includes("mp4") ? "mp4" : "wav";
const form = new FormData();
form.append("file", new Blob([buf], { type: mime || "audio/webm" }), `voice.${ext}`);
form.append("model", model);
form.append("response_format", "json");
const res = await fetch(base + "/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: "Bearer " + g.key },
body: form,
});
dlog("转写请求 →", base + "/v1/audio/transcriptions", "model=" + model, "size=" + buf.length, "HTTP " + res.status);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`转写接口 HTTP ${res.status}:${detail.slice(0, 300)}`);
}
const j = await res.json();
return (j && (j.text || "")).trim();
}
// —— 主进程收到页面 binding 回传:处理转写并把文字送回页面 ——
async function handleBinding(params) {
const name = params && params.name;
if (name !== BINDING_NAME) return;
let payload;
try {
payload = JSON.parse(params.payload);
} catch {
return;
}
if (!payload) return;
if (
payload.kind === "delete-session" ||
payload.kind === "delete-session-clipboard-prepare" ||
payload.kind === "delete-session-clipboard-read"
) {
if (onSessionDeleteCb) {
try { await onSessionDeleteCb(payload); } catch (e) { dlog("永久删除会话请求失败:", e && e.message); }
}
return;
}
if (payload.kind === "debug-sidebar-dump") {
dlog("侧边栏诊断:", JSON.stringify(payload.info || {}));
return;
}
if (payload.kind !== "transcribe") return;
const { id, audioB64, mime } = payload;
dlog("binding 收到转写请求 id=" + id, "mime=" + mime, "audioB64长度=" + (audioB64?audioB64.length:0));
setPhase("transcribing");
let text = "";
let err = "";
try {
text = await runStt(audioB64, mime);
dlog("转写结果:", JSON.stringify(text).slice(0, 120));
} catch (e) {
err = String((e && e.message) || e);
dlog("转写失败:", err);
}
setPhase(connectedPhase());
// 把结果回调给页面
const expr = `window.__voiceInjectHandle && window.__voiceInjectHandle(${JSON.stringify(
{ id, text, err }
)})`;
try {
if (session) await session.send("Runtime.evaluate", { expression: expr, awaitPromise: false });
} catch {}
}
function connectedPhase() {
return session ? "connected" : "disconnected";
}
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
async function inject(sessionObj) {
// 清除 require 缓存,使 voice_inject.js 的修改能在重连时生效,无需重启整个应用
delete require.cache[require.resolve("./voice_inject")];
delete require.cache[require.resolve("./token_inject")];
const { INJECT_SCRIPT: FRESH_INJECT } = require("./voice_inject");
const { TOKEN_INJECT_SCRIPT: FRESH_TOKEN } = require("./token_inject");
await sessionObj.send("Runtime.enable");
try {
await sessionObj.send("Runtime.removeBinding", { name: BINDING_NAME });
} catch {}
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
await sessionObj.send("Page.enable");
// 语音注入脚本
const voiceScript = await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_INJECT });
voiceScriptIdentifier = (voiceScript && voiceScript.identifier) || "";
await sessionObj.send("Runtime.evaluate", { expression: FRESH_INJECT, awaitPromise: false });
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_TOKEN });
await sessionObj.send("Runtime.evaluate", { expression: FRESH_TOKEN, awaitPromise: false });
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
await sessionObj.send("Network.enable");
}
// ============================================================================
// Token 用量:CDP Network 域拦截 LLM 响应抓 usage(不受页面 CSP 限制)
// ============================================================================
// 待完成的请求:requestId → { url, mimeType }
const pendingNetworkReqs = new Map();
let nextNetworkSessionId = 1;
const tokenNet = {
matchedCount: 0,
usageCount: 0,
noUsageCount: 0,
lastMatchedUrl: "",
lastUsageUrl: "",
lastNoUsageUrl: "",
lastUsageTotal: 0,
lastError: "",
attachedWorkers: 0,
};
const LLM_URL_RE = /\/(chat\/completions|v1\/messages|messages|generateContent|streamGenerateContent|completions|responses|backend-api\/(?:codex|conversation|responses)|codex|conversation)(\/|$|\?)/i;
function networkReqKey(ownerSession, requestId) {
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
return ownerSession._tdTokenNetId + ":" + requestId;
}
function bindNetworkHandlers(ownerSession) {
if (!ownerSession) return;
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
ownerSession.on("Network.responseReceived", (params) => handleNetworkResponse(params, ownerSession));
ownerSession.on("Network.loadingFinished", (params) => handleNetworkLoadingFinished(params, ownerSession));
ownerSession.on("Network.loadingFailed", (params) => handleNetworkLoadingFailed(params, ownerSession));
}
function handleNetworkResponse(params, ownerSession) {
const { requestId, response } = params || {};
if (!requestId || !response || !ownerSession) return;
const url = response.url || "";
if (!LLM_URL_RE.test(url)) return;
// 只处理 JSON 响应
const mime = (response.mimeType || "").toLowerCase();
if (!mime.includes("json") && !mime.includes("text")) return;
tokenNet.matchedCount += 1;
tokenNet.lastMatchedUrl = url;
pendingNetworkReqs.set(networkReqKey(ownerSession, requestId), { url, mimeType: mime });
}
function handleNetworkLoadingFailed(params, ownerSession) {
const { requestId } = params || {};
if (requestId && ownerSession) pendingNetworkReqs.delete(networkReqKey(ownerSession, requestId));
}
async function handleNetworkLoadingFinished(params, ownerSession) {
const { requestId } = params || {};
if (!requestId || !ownerSession) return;
const key = networkReqKey(ownerSession, requestId);
if (!pendingNetworkReqs.has(key)) return;
const reqInfo = pendingNetworkReqs.get(key) || {};
pendingNetworkReqs.delete(key);
if (!session || !ownerSession) return;
try {
const r = await ownerSession.send("Network.getResponseBody", { requestId });
const body = (r && r.body) || "";
if (!body || body.length < 10) return;
// 可能被 base64 编码
let text = body;
if (r && r.base64Encoded) {
try { text = Buffer.from(body, "base64").toString("utf8"); } catch { return; }
}
// 解析 JSON 提取 usage
const usage = extractUsageFromText(text);
if (usage && usage.total > 0) {
tokenNet.usageCount += 1;
tokenNet.lastUsageUrl = reqInfo.url || "";
tokenNet.lastUsageTotal = usage.total || 0;
tokenNet.lastError = "";
dlog(
"token: +" + usage.total +
" input=" + (usage.input || 0) +
" output=" + (usage.output || 0) +
" cached=" + (usage.cached || 0) +
" from",
reqInfo.url || "?"
);
// 通过 Runtime.evaluate 调注入脚本更新 UI
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenAdd && window.__tdTokenAdd(" + JSON.stringify(usage) + ", 'network')",
awaitPromise: false,
});
} catch {}
} else {
tokenNet.noUsageCount += 1;
tokenNet.lastNoUsageUrl = reqInfo.url || "";
}
} catch (e) {
tokenNet.lastError = String((e && e.message) || e || "getResponseBody failed").slice(0, 160);
// getResponseBody 可能失败(响应体已被清掉或太大),静默跳过
}
}
function extractUsageFromText(text) {
let best = null;
const take = (obj) => {
best = betterUsage(best, extractUsageFromObject(obj));
};
try {
take(JSON.parse(text));
} catch {}
if (usageTotal(best) > 0) return best;
// Stream/SSE: data: {"usage":...}\n\n or data: [DONE]
const lines = String(text || "").split(/\r?\n/);
for (const line of lines) {
const s = line.trim();
const payload = s.startsWith("data:") ? s.slice(5).trim() : s;
if (!payload || payload === "[DONE]") continue;
try { take(JSON.parse(payload)); } catch {}
}
return best;
}
function cleanTokenNumber(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function usageTotal(u) {
return u ? cleanTokenNumber(u.total) : 0;
}
function betterUsage(a, b) {
return usageTotal(b) > usageTotal(a) ? b : a;
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function looksLikeUsage(usage) {
if (!usage || typeof usage !== "object") return false;
const keys = [
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
"thoughtsTokenCount", "thoughts_token_count",
];
if (keys.some((key) => hasOwn(usage, key))) return true;
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
}
function extractUsageFromRawUsage(usage) {
if (!usage || typeof usage !== "object") return null;
if (!looksLikeUsage(usage)) return null;
const input = cleanTokenNumber(
usage.input_tokens ||
usage.prompt_tokens ||
usage.promptTokenCount ||
usage.prompt_token_count ||
usage.inputTokenCount
);
const output = cleanTokenNumber(
usage.output_tokens ||
usage.completion_tokens ||
usage.candidatesTokenCount ||
usage.candidates_token_count ||
usage.outputTokenCount
);
const reasoning = cleanTokenNumber(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
const cached =
cleanTokenNumber(details.cached_tokens) +
cleanTokenNumber(usage.cache_creation_input_tokens) +
cleanTokenNumber(usage.cache_read_input_tokens) +
cleanTokenNumber(usage.cachedContentTokenCount) +
cleanTokenNumber(usage.cached_content_token_count);
const total = cleanTokenNumber(
usage.total_tokens ||
usage.totalTokenCount ||
usage.total_token_count ||
usage.token_count ||
usage.total
) || input + output + reasoning + cached;
if (!total) return null;
return { total, input, output: output + reasoning, cached };
}
function extractUsageFromObject(obj) {
if (!obj || typeof obj !== "object") return null;
const direct =
extractUsageFromRawUsage(obj.usage) ||
extractUsageFromRawUsage(obj.usage_metadata) ||
extractUsageFromRawUsage(obj.usageMetadata) ||
extractUsageFromRawUsage(obj);
if (direct) return direct;
let best = null;
for (const val of Object.values(obj)) {
if (!val || typeof val !== "object") continue;
if (Array.isArray(val)) {
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
} else {
best = betterUsage(best, extractUsageFromObject(val));
}
}
return best;
}
async function attachNetworkSession(target, generation) {
if (!target || !target.webSocketDebuggerUrl) return;
let netSession = null;
try {
netSession = await openSession(target.webSocketDebuggerUrl, null);
bindNetworkHandlers(netSession);
await netSession.send("Network.enable");
if (!session || generation !== networkAttachGeneration) {
try { netSession.close(); } catch {}
return false;
}
networkSessions.push(netSession);
dlog("token network attached:", target.type || "target", target.id || "", target.url || "");
return true;
} catch (e) {
if (netSession) {
try { netSession.close(); } catch {}
}
tokenNet.lastError = String((e && e.message) || e || "attach network failed").slice(0, 160);
return false;
}
}
async function attachWorkerNetworkTargets(port, parentId) {
const targets = await listTargets(port);
const workers = targets.filter((t) =>
t &&
t.webSocketDebuggerUrl &&
(t.type === "worker" || t.type === "service_worker" || t.type === "shared_worker") &&
(!parentId || t.parentId === parentId)
);
const generation = networkAttachGeneration;
const results = await Promise.all(workers.map((worker) => attachNetworkSession(worker, generation)));
tokenNet.attachedWorkers = results.filter(Boolean).length;
}
function closeNetworkSessions() {
networkAttachGeneration += 1;
for (const s of networkSessions) {
try { s.close(); } catch {}
}
networkSessions = [];
pendingNetworkReqs.clear();
}
// —— 连接到已带端口的 Codex ——
async function connectToTarget(port) {
const targets = await listTargets(port);
// listTargets() keeps its legacy [] fallback for the voice state machine.
// Let the cleanup module probe the endpoint itself so a failed connection
// cannot be mistaken for an available endpoint with no targets.
await globalDictation.closeGlobalDictationTargets(port);
const tgt = pickTarget(targets);
if (!tgt) return false;
closeNetworkSessions();
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
setPhase("connecting");
const ownedSession = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
session = ownedSession;
// Token 用量:Network 层拦截 LLM 响应
bindNetworkHandlers(ownedSession);
ownedSession.ws.on("close", () => {
if (session !== ownedSession) return;
stopTokenSync();
closeNetworkSessions();
session = null;
setPhase("disconnected");
});
setPhase("injecting");
await inject(ownedSession);
startTokenSync();
setPhase("connected", { target: state.target });
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
tokenNet.lastError = String((e && e.message) || e || "attach workers failed").slice(0, 160);
});
return true;
}
// —— 轮询直到出现 page target(重启后用)——
async function waitForReady(port) {
const t0 = Date.now();
const maxMs = state.codexPackaged ? PACKAGED_READY_POLL_MAX_MS : READY_POLL_MAX_MS;
let diagnosed = false;
while (Date.now() - t0 < maxMs) {
const targets = await listTargets(port);
const tgt = pickTarget(targets);
if (tgt) return tgt;
if (!diagnosed && process.platform === "win32" && Date.now() - t0 >= 5000) {
diagnosed = true;
const d = updateWindowsProcessDiagnostic(port);
setPhase("waiting_debug_port", { portDiagnostic: state.portDiagnostic });
if (state.codexPackaged && d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg) {
return null;
}
}
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL));
}
updateWindowsProcessDiagnostic(port);
return null;
}
function spawnCodex(exePath, port) {
if (state.codexPackaged && process.platform === "win32") {
return windowsShellStart(exePath, port);
}
return new Promise((resolve, reject) => {
let child = null;
try {
const args = process.platform === "win32" ? windowsLaunchArgs(port) : [`--remote-debugging-port=${port}`];
child = spawn(exePath, args, {
detached: true,
stdio: "ignore",
windowsHide: false,
});
} catch (e) {
reject(e);
return;
}
state.launchWarning = "";
let settled = false;
const finish = (err) => {
if (settled) return;
settled = true;
if (err) {
reject(err);
return;
}
try {
child.unref();
} catch {}
resolve(true);
};
child.once("error", (err) => {
if (process.platform === "win32" && /eperm/i.test(String((err && err.message) || err || ""))) {
windowsShellStart(exePath, port).then(() => finish(null)).catch(finish);
return;
}
finish(err);
});
child.once("spawn", () => finish(null));
});
}
// —— 对外:status / connect / disconnect ——
function status() {
return snapshot();
}
function onStatus(cb) {
onStatusCb = cb;
}
function onSessionDelete(cb) {
onSessionDeleteCb = cb;
}
// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。
async function connect(cb, opts = {}) {
if (cb) onStatusCb = cb;
const port = opts.port || DEFAULT_PORT;
globalDictation.disableKeepVisible();
state.port = port;
setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" });
const exe = scanCodexExecutable();
// 1. 端口已开?
const ok = await connectToTarget(port);
if (ok) return snapshot();
// 2. 判断 Codex 是否在跑
const running = codexDesktop.codexRunning();
if (!running && !opts.forceRestart) {
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
if (!exe) {
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
return snapshot();
}
setPhase("restarting", { codexPath: exe });
try {
await spawnCodex(exe, port);
} catch (e) {
setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery });
return snapshot();
}
setPhase("waiting_debug_port", { codexPath: exe, lastError: "" });
const tgt = await waitForReady(port);
if (!tgt) {
setDebugPortFailure("启动", port);
return snapshot();
}
const ok2 = await connectToTarget(port);
if (!ok2) setPhase("error", { lastError: "已启动但未找到可注入页面" });
return snapshot();
}
// 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启
if (running && !opts.forceRestart) {
setPhase("running_no_port", { codexPath: state.codexPath });
return snapshot(); // 前端据此弹"重启确认"
}
// 4. forceRestart:杀进程 → 重启
if (!exe) {
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
return snapshot();
}
state.codexPath = exe;
setPhase("restarting");
await codexDesktop.killCodex();
try {
await spawnCodex(exe, port);
} catch (e) {
setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery });
return snapshot();
}
setPhase("waiting_debug_port", { codexPath: exe, lastError: "" });
const tgt = await waitForReady(port);
if (!tgt) {
setDebugPortFailure("重启", port);
return snapshot();
}
const ok3 = await connectToTarget(port);
if (!ok3) setPhase("error", { lastError: "已启动但未找到可注入页面" });
return snapshot();
}
async function disconnect() {
stopTokenSync();
closeNetworkSessions();
globalDictation.disableKeepVisible();
if (session) {
const ownedSession = session;
// 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。
try {
await ownedSession.send("Runtime.evaluate", {
expression: "window.__voiceInjectDispose && window.__voiceInjectDispose()",
awaitPromise: false,
});
} catch {}
if (voiceScriptIdentifier) {
try {
await ownedSession.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: voiceScriptIdentifier });
} catch {}
voiceScriptIdentifier = "";
}
session = null;
try {
ownedSession.close();
} catch {}
}
setPhase("disconnected");
await globalDictation.closeGlobalDictationTargets(state.port);
return snapshot();
}
// 当前语音转写配置(来自路由里勾选 STT 的分组),供前端只读展示。
function sttInfo() {
const g = router.sttGroup();
if (!g) return { ready: false, groupName: "", model: "" };
return {
ready: true,
groupName: g.name || "",
model: g.sttModel || "whisper-1",
baseUrl: (g.baseUrl || "").replace(/\/+$/, ""),
};
}
// 诊断:用每个非默认分组的真 key 探测 /v1/audio/transcriptions 是否存在。
// 404 = 路径不存在(不支持STT);400/401/4xx-body非404/200 = 路径存在(支持,鉴权/参数问题另算)。
async function probeStt() {
const groups = router.usableGroups ? router.usableGroups() : [];
const out = [];
const body = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]); // 假 webm 头,只为触发路径判断
for (const g of groups) {
const base = (g.baseUrl || "").replace(/\/+$/, "");
if (!base || !g.key) { out.push({ name: g.name, baseUrl: base, status: 0, note: "缺地址或密钥" }); continue; }
try {
const form = new FormData();
form.append("file", new Blob([body], { type: "audio/webm" }), "v.webm");
form.append("model", g.sttModel || "whisper-1");
const res = await fetch(base + "/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: "Bearer " + g.key },
body: form,
});
const text = await res.text().catch(() => "");
const notFound = res.status === 404 || /404 page not found|not found/i.test(text.slice(0, 60));
out.push({
name: g.name,
baseUrl: base,
status: res.status,
supported: !notFound,
note: text.slice(0, 120).replace(/\s+/g, " "),
});
} catch (e) {
out.push({ name: g.name, baseUrl: base, status: 0, supported: false, note: String((e && e.message) || e).slice(0, 120) });
}
}
return out;
}
// —— Token 用量读取 / 重置(通过 CDP Runtime.evaluate 调注入脚本的 window API)——
async function tokenStatus() {
if (!session) return { date: "", total: 0 };
try {
const r = await session.send("Runtime.evaluate", {
expression: "window.__tdTokenGet ? window.__tdTokenGet() : JSON.stringify({date:'',total:0})",
awaitPromise: false,
});
const raw = (r && r.result && r.result.value) || "";
const parsed = (() => { try { return JSON.parse(raw); } catch { return { date: "", total: 0 }; } })();
return {
date: parsed.date || "",
total: Number(parsed.total) || 0,
input: Number(parsed.input) || 0,
output: Number(parsed.output) || 0,
cached: Number(parsed.cached) || 0,
estimate: Number(parsed.estimate || parsed.estimated) || 0,
unknown: Number(parsed.unknown) || 0,
lastSource: parsed.lastSource || parsed.source || "",
network: { ...tokenNet },
};
} catch {
return { date: "", total: 0 };
}
}
async function tokenSet(usage) {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage || {}) + ")",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
async function tokenReset() {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenReset ? window.__tdTokenReset() : 'no'",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
module.exports = {
status,
connect,
disconnect,
onStatus,
onSessionDelete,
sttInfo,
probeStt,
tokenStatus,
tokenSet,
tokenReset,
// 供临时实测脚本复用
_listTargets: listTargets,
_pickTarget: pickTarget,
_openSession: openSession,
_inject: inject,
DEFAULT_PORT,
};