657 lines
20 KiB
JavaScript
657 lines
20 KiB
JavaScript
|
|
"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,
|
||
|
|
};
|