feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空

This commit is contained in:
mac
2026-09-11 09:06:41 +08:00
parent 0446d09b3b
commit a74576c3e1
28 changed files with 3217 additions and 155 deletions
+656
View File
@@ -0,0 +1,656 @@
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const KEEP_VISIBLE_KEY = "global-dictation-keep-visible";
const GLOBAL_DICTATION_ROUTE = "/global-dictation";
const DEFAULT_HTTP_TIMEOUT_MS = 1500;
const DEFAULT_OWNER_TIMEOUT_MS = 5000;
const DEFAULT_CLOSE_SETTLE_MS = 100;
const DEFAULT_CLOSE_RETRIES = 2;
const RETRYABLE_IO_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
let codexHome = path.join(os.homedir(), ".codex");
let watchedStateFile = "";
let guardLog = () => {};
let guardBusy = false;
let endpointOwnerVerifier = null;
const cleanupFlights = new Map();
function sleepSync(ms) {
if (!ms) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function retryIo(fn) {
const delays = [0, 10, 25, 50, 100, 200];
let lastError;
for (const delay of delays) {
if (delay) sleepSync(delay);
try {
return fn();
} catch (e) {
lastError = e;
if (!RETRYABLE_IO_CODES.has(e && e.code)) throw e;
}
}
throw lastError;
}
function setCodexDataDir(dir) {
if (typeof dir !== "string" || !dir.trim() || !path.isAbsolute(dir.trim())) {
throw new Error("Codex data directory must be an absolute path");
}
codexHome = path.resolve(dir.trim());
}
function setEndpointOwnerVerifier(verifier) {
endpointOwnerVerifier = typeof verifier === "function" ? verifier : null;
}
function stateFileFor(home = codexHome) {
return path.join(home, ".codex-global-state.json");
}
function acquireLock(file, waitMs = 600) {
const lockFile = file + ".td-dictation.lock";
const deadline = Date.now() + Math.max(0, waitMs);
while (true) {
try {
const fd = fs.openSync(lockFile, "wx", 0o600);
return { fd, lockFile };
} catch (e) {
if (!e || e.code !== "EEXIST") throw e;
try {
const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
if (ageMs > 30000) {
fs.rmSync(lockFile, { force: true });
continue;
}
} catch {}
if (Date.now() >= deadline) return null;
sleepSync(25);
}
}
}
function releaseLock(lock) {
if (!lock) return;
try { fs.closeSync(lock.fd); } catch {}
try { fs.rmSync(lock.lockFile, { force: true }); } catch {}
}
function writeCompleteTemp(file, content, mode, label) {
const tmp = `${file}.${process.pid}.${Date.now()}.${label}.tmp`;
const fd = fs.openSync(tmp, "wx", mode || 0o600);
try {
fs.writeFileSync(fd, content);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
try { fs.chmodSync(tmp, mode || 0o600); } catch {}
return tmp;
}
function writeBackupOnce(file, content, mode) {
const backup = file + ".td-before-global-dictation.bak";
let tmp = "";
try {
tmp = writeCompleteTemp(backup, content, mode, "backup");
fs.linkSync(tmp, backup);
} catch (e) {
if (!e || e.code !== "EEXIST") throw e;
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
return backup;
}
function isJsonWhitespaceByte(value) {
return value === 0x20 || value === 0x09 || value === 0x0a || value === 0x0d;
}
function patchKeepVisibleFalse(file, original) {
const text = original.toString("latin1");
const pattern = new RegExp(`"${KEEP_VISIBLE_KEY}"\\s*:\\s*true`, "g");
let match;
let valueOffset = -1;
while ((match = pattern.exec(text))) {
valueOffset = match.index + match[0].lastIndexOf("true");
}
if (valueOffset < 1 || !isJsonWhitespaceByte(original[valueOffset - 1])) {
return { ok: false, changed: false, reason: "unsafe-format" };
}
const patchOffset = valueOffset - 1;
let fd;
try {
fd = retryIo(() => fs.openSync(file, "r+"));
const current = retryIo(() => fs.readFileSync(fd));
if (!current.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
const descriptorStat = fs.fstatSync(fd);
const pathStat = fs.statSync(file);
if (descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
const patch = Buffer.from("false", "ascii");
const written = fs.writeSync(fd, patch, 0, patch.length, patchOffset);
if (written !== patch.length) throw new Error("global dictation state short write");
fs.fsyncSync(fd);
} finally {
if (fd !== undefined) {
try { fs.closeSync(fd); } catch {}
}
}
const saved = retryIo(() => fs.readFileSync(file));
try {
const parsed = JSON.parse(saved.toString("utf8"));
if (parsed && parsed[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: true, reason: "disabled" };
}
} catch {}
return { ok: false, changed: false, reason: "concurrent-write" };
}
function sleepAsync(ms, signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted"));
return;
}
const timer = setTimeout(done, Math.max(0, Number(ms) || 0));
function done() {
if (signal) signal.removeEventListener("abort", aborted);
resolve();
}
function aborted() {
clearTimeout(timer);
signal.removeEventListener("abort", aborted);
reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted"));
}
if (signal) signal.addEventListener("abort", aborted, { once: true });
});
}
function createFileIfMissing(file, content, mode) {
let tmp = "";
try {
tmp = writeCompleteTemp(file, content, mode, "td-dictation-create");
try {
fs.linkSync(tmp, file);
} catch (e) {
if (e && e.code === "EEXIST") return false;
throw e;
}
const written = retryIo(() => fs.readFileSync(file));
return written.equals(content);
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
}
function rewriteStateAtomically(file, original, state, mode) {
const next = Buffer.from(JSON.stringify(state), "utf8");
if (next.equals(original)) {
return { ok: true, changed: false, reason: "already-disabled" };
}
let tmp = "";
try {
const current = retryIo(() => fs.readFileSync(file));
if (!current.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
tmp = writeCompleteTemp(file, next, mode, "td-dictation-atomic");
const beforeRename = retryIo(() => fs.readFileSync(file));
if (!beforeRename.equals(original)) {
return { ok: false, changed: false, reason: "concurrent-write" };
}
fs.renameSync(tmp, file);
tmp = "";
const saved = retryIo(() => fs.readFileSync(file));
const parsed = JSON.parse(saved.toString("utf8"));
if (parsed && parsed[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: true, reason: "disabled-atomic" };
}
return { ok: false, changed: false, reason: "verification-failed" };
} finally {
if (tmp) {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
}
}
function disableKeepVisible(options = {}) {
const home = options.codexHome ? path.resolve(options.codexHome) : codexHome;
const file = stateFileFor(home);
let lock;
try {
lock = acquireLock(file, options.lockWaitMs);
if (!lock) return { ok: false, changed: false, reason: "locked", file };
for (let attempt = 0; attempt < 5; attempt++) {
if (!fs.existsSync(file)) {
const next = Buffer.from(JSON.stringify({ [KEEP_VISIBLE_KEY]: false }, null, 2) + "\n", "utf8");
if (createFileIfMissing(file, next, 0o600)) {
return { ok: true, changed: true, reason: "created-disabled", file };
}
continue;
}
const original = retryIo(() => fs.readFileSync(file));
let state;
try {
state = JSON.parse(original.toString("utf8"));
} catch (e) {
return { ok: false, changed: false, reason: "invalid-json", error: e.message, file };
}
if (!state || typeof state !== "object" || Array.isArray(state)) {
return { ok: false, changed: false, reason: "invalid-state", file };
}
if (state[KEEP_VISIBLE_KEY] === false) {
return { ok: true, changed: false, reason: "already-disabled", file };
}
state[KEEP_VISIBLE_KEY] = false;
const mode = fs.statSync(file).mode & 0o777;
writeBackupOnce(file, original, mode);
const patched = patchKeepVisibleFalse(file, original);
if (patched.ok) return { ...patched, file };
if (patched.reason === "unsafe-format") {
const rewritten = rewriteStateAtomically(file, original, state, mode);
if (rewritten.ok) return { ...rewritten, file };
if (rewritten.reason === "concurrent-write") continue;
return { ...rewritten, file };
}
if (patched.reason === "concurrent-write") continue;
return { ...patched, file };
}
return { ok: false, changed: false, reason: "concurrent-write", file };
} catch (e) {
return {
ok: false,
changed: false,
reason: "write-failed",
error: String((e && e.message) || e),
file,
};
} finally {
releaseLock(lock);
}
}
function decodeRoute(value) {
let route = String(value || "").trim();
for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(route); i++) {
try { route = decodeURIComponent(route); } catch { break; }
}
if (route && !route.startsWith("/")) route = "/" + route;
return route.toLowerCase();
}
function parseCodexPageUrl(rawUrl) {
try {
const url = new URL(String(rawUrl || ""));
const pathname = url.pathname.toLowerCase();
if (
url.protocol !== "app:" ||
url.hostname !== "-" ||
(pathname !== "/index.html" && pathname !== "/index.html/")
) return null;
return url;
} catch {}
return null;
}
function routeInfo(rawUrl) {
const url = parseCodexPageUrl(rawUrl);
if (!url) return { url: null, present: false, route: "" };
for (const [key, value] of url.searchParams.entries()) {
if (key.toLowerCase() === "initialroute") {
return { url, present: true, route: decodeRoute(value) };
}
}
return { url, present: false, route: "" };
}
function initialRoute(rawUrl) {
return routeInfo(rawUrl).route;
}
function isCodexMainTarget(target) {
if (!target || String(target.type || "").toLowerCase() !== "page") return false;
const info = routeInfo(target.url);
return !!info.url && !info.present;
}
function isGlobalDictationTarget(target) {
if (!target || String(target.type || "").toLowerCase() !== "page") return false;
const info = routeInfo(target.url);
return !!info.url && info.present && info.route === GLOBAL_DICTATION_ROUTE;
}
async function fetchWithTimeout(fetchImpl, url, timeoutMs, signal) {
return runWithTimeout(
(requestSignal) => fetchImpl(url, { signal: requestSignal }),
timeoutMs,
signal,
);
}
function abortError(message, code) {
const error = new Error(message);
error.name = "AbortError";
if (code) error.code = code;
return error;
}
async function runWithTimeout(task, timeoutMs, signal) {
const controller = new AbortController();
let timer;
let abortListener;
const interrupted = new Promise((_, reject) => {
timer = setTimeout(() => {
const error = abortError("request timed out", "ETIMEDOUT");
controller.abort(error);
reject(error);
}, Math.max(1, Number(timeoutMs) || DEFAULT_HTTP_TIMEOUT_MS));
if (signal) {
abortListener = () => {
const reason = signal.reason instanceof Error
? signal.reason
: abortError(signal.reason ? String(signal.reason) : "request aborted");
controller.abort(reason);
reject(reason);
};
if (signal.aborted) abortListener();
else signal.addEventListener("abort", abortListener, { once: true });
}
});
try {
return await Promise.race([
Promise.resolve().then(() => task(controller.signal)),
interrupted,
]);
} finally {
clearTimeout(timer);
if (signal && abortListener) signal.removeEventListener("abort", abortListener);
}
}
async function listTargets(port, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
return { available: false, status: "failed", targets: [], error: "fetch unavailable" };
}
try {
const { response, targets } = await runWithTimeout(
async (requestSignal) => {
const response = await fetchImpl(`http://127.0.0.1:${Number(port)}/json`, {
signal: requestSignal,
});
const targets = response && response.ok ? await response.json() : null;
return { response, targets };
},
options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS,
options.signal,
);
if (!response || !response.ok) {
return {
available: false,
status: "port-unavailable",
targets: [],
error: response ? `HTTP ${response.status || "error"}` : "empty response",
};
}
if (!Array.isArray(targets)) {
return { available: false, status: "failed", targets: [], error: "invalid target list" };
}
return { available: true, status: "available", targets, error: "" };
} catch (e) {
const aborted = !!(options.signal && options.signal.aborted);
const timedOut = !!(e && e.code === "ETIMEDOUT");
return {
available: false,
status: aborted ? "aborted" : timedOut ? "timeout" : "port-unavailable",
targets: [],
error: String((e && e.message) || e),
};
}
}
async function runCloseGlobalDictationTargets(port, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
const listed = Array.isArray(options.targets)
? { available: true, status: "available", targets: options.targets, error: "" }
: await listTargets(port, options);
const matches = listed.targets.filter(isGlobalDictationTarget);
const result = {
ok: listed.available,
status: listed.status,
available: listed.available,
matched: matches.length,
closed: 0,
remaining: listed.available ? matches.length : null,
targetIds: matches.map((target) => target.id).filter(Boolean),
errors: [],
};
if (!listed.available || typeof fetchImpl !== "function") {
result.status = listed.status || "port-unavailable";
if (listed.error) result.errors.push(listed.error);
return result;
}
if (!matches.length) {
result.status = "not-found";
result.remaining = 0;
return result;
}
if (!listed.targets.some(isCodexMainTarget)) {
const verifier = options.endpointOwnerVerifier || endpointOwnerVerifier;
let ownership = null;
if (typeof verifier === "function") {
try {
ownership = await runWithTimeout(
(requestSignal) => verifier(Number(port), {
signal: requestSignal,
timeoutMs: options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS,
}),
options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS,
options.signal,
);
} catch (e) {
ownership = {
verified: false,
owned: false,
reason: String((e && e.message) || e),
};
}
}
const owned = ownership === true || !!(ownership && ownership.owned);
if (!owned) {
result.ok = false;
result.status = ownership && ownership.verified ? "foreign-endpoint" : "unverified-endpoint";
result.errors.push((ownership && ownership.reason) || "Codex endpoint ownership could not be verified");
return result;
}
}
const closeResults = await Promise.all(matches.map(async (target) => {
if (!target.id) {
return { closed: false, error: "global dictation target has no id" };
}
try {
const response = await fetchWithTimeout(
fetchImpl,
`http://127.0.0.1:${Number(port)}/json/close/${encodeURIComponent(target.id)}`,
options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS,
options.signal,
);
if (response && response.ok) return { closed: true, error: "" };
return { closed: false, error: `failed to close target ${target.id}` };
} catch (e) {
return { closed: false, error: String((e && e.message) || e) };
}
}));
result.closed = closeResults.filter((item) => item.closed).length;
result.errors.push(...closeResults.map((item) => item.error).filter(Boolean));
if (result.errors.length) {
result.ok = false;
result.status = options.signal && options.signal.aborted ? "aborted" : "failed";
return result;
}
const settleMs = options.settleMs === undefined
? DEFAULT_CLOSE_SETTLE_MS
: Math.max(0, Number(options.settleMs) || 0);
if (settleMs) {
try {
await sleepAsync(settleMs, options.signal);
} catch (e) {
result.ok = false;
result.status = "aborted";
result.remaining = null;
result.errors.push(String((e && e.message) || e));
return result;
}
}
const verified = await listTargets(port, options);
if (!verified.available) {
result.ok = false;
result.status = verified.status === "aborted" ? "aborted" : "verification-failed";
result.remaining = null;
if (verified.error) result.errors.push(verified.error);
return result;
}
const remaining = verified.targets.filter(isGlobalDictationTarget);
result.remaining = remaining.length;
if (remaining.length) {
result.ok = false;
result.status = "still-present";
result.targetIds = remaining.map((target) => target.id).filter(Boolean);
return result;
}
result.status = "closed";
return result;
}
async function runCloseGlobalDictationWithRetry(port, options = {}) {
const retries = options.retries === undefined
? DEFAULT_CLOSE_RETRIES
: Math.max(0, Number(options.retries) || 0);
const retryDelayMs = options.retryDelayMs === undefined
? 120
: Math.max(0, Number(options.retryDelayMs) || 0);
let totalClosed = 0;
let lastResult = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const attemptOptions = attempt === 0
? options
: { ...options, targets: undefined };
lastResult = await runCloseGlobalDictationTargets(port, attemptOptions);
totalClosed += lastResult.closed || 0;
if (lastResult.status !== "still-present") {
return { ...lastResult, closed: totalClosed, attempts: attempt + 1 };
}
if (attempt < retries && retryDelayMs) {
try {
await sleepAsync(retryDelayMs, options.signal);
} catch (e) {
return {
...lastResult,
ok: false,
status: "aborted",
closed: totalClosed,
remaining: null,
attempts: attempt + 1,
errors: [...lastResult.errors, String((e && e.message) || e)],
};
}
}
}
return { ...lastResult, closed: totalClosed, attempts: retries + 1 };
}
function closeGlobalDictationTargets(port, options = {}) {
const hasCustomOptions =
!!options.fetchImpl ||
!!options.signal ||
!!options.endpointOwnerVerifier ||
Array.isArray(options.targets) ||
options.timeoutMs !== undefined ||
options.ownerTimeoutMs !== undefined ||
options.settleMs !== undefined ||
options.retries !== undefined ||
options.retryDelayMs !== undefined;
if (hasCustomOptions) return runCloseGlobalDictationWithRetry(port, options);
const key = String(Number(port));
if (cleanupFlights.has(key)) return cleanupFlights.get(key);
const flight = runCloseGlobalDictationWithRetry(port, options).finally(() => {
if (cleanupFlights.get(key) === flight) cleanupFlights.delete(key);
});
cleanupFlights.set(key, flight);
return flight;
}
function enforceGuard() {
if (guardBusy) return;
guardBusy = true;
try {
const result = disableKeepVisible();
if (result.changed) guardLog("已关闭 Codex 全局听写栏常驻显示");
else if (!result.ok && result.reason !== "locked") {
guardLog(`关闭 Codex 全局听写栏常驻显示失败:${result.error || result.reason}`);
}
} finally {
guardBusy = false;
}
}
function startGuard(options = {}) {
stopGuard();
if (options.codexHome) setCodexDataDir(options.codexHome);
guardLog = typeof options.log === "function" ? options.log : () => {};
watchedStateFile = stateFileFor();
const initial = disableKeepVisible();
fs.watchFile(watchedStateFile, { interval: options.intervalMs || 2000, persistent: false }, enforceGuard);
return initial;
}
function stopGuard() {
if (watchedStateFile) {
try { fs.unwatchFile(watchedStateFile, enforceGuard); } catch {}
}
watchedStateFile = "";
guardLog = () => {};
}
module.exports = {
KEEP_VISIBLE_KEY,
GLOBAL_DICTATION_ROUTE,
setCodexDataDir,
setEndpointOwnerVerifier,
disableKeepVisible,
startGuard,
stopGuard,
isCodexMainTarget,
isGlobalDictationTarget,
closeGlobalDictationTargets,
_initialRoute: initialRoute,
_listTargets: listTargets,
_stateFileFor: stateFileFor,
};