feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user