Files
Netcatty/electron/scripts/scriptRuntime.cjs
T
2026-07-02 17:48:04 +08:00

477 lines
14 KiB
JavaScript

"use strict";
const vm = require("node:vm");
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
function wrapScriptSource(source) {
const trimmed = String(source || "").trim();
if (!trimmed) {
throw new Error("Script content is empty");
}
if (/^\(\s*async\s*\(\s*\)\s*=>/m.test(trimmed) || /^\(\s*async\s+function\s*\(\s*\)\s*\{/m.test(trimmed)) {
return trimmed;
}
const hasMainFunction = /\basync\s+function\s+main\s*\(/m.test(trimmed) || /\bfunction\s+main\s*\(/m.test(trimmed);
if (hasMainFunction) {
const body = trimmed.replace(/\n\s*;?\s*await\s+main\s*\(\s*\)\s*;?\s*$/m, "").trimEnd();
const invokeMain = /\basync\s+function\s+main\s*\(/m.test(body)
? "await main();"
: "await Promise.resolve(main());";
return `(async () => {\n${body}\n${invokeMain}\n})();`;
}
return `(async () => {\n${trimmed}\n})();`;
}
function truncateActivityLabel(value, max = 80) {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
function createScriptRuntime(deps) {
const {
sessionId,
runId,
appendLog,
writeToSession,
getOutputBuffer,
getScreenSnapshot,
getSessionMeta,
showDialog,
showWaitForTimeoutDialog,
disconnectSession,
startSessionLog,
stopSessionLog,
onStatusChange,
isPaused,
permissionMode = "auto",
startedAt = Date.now(),
} = deps;
let stepIndex = 0;
let progressMode = "activity";
let progressLabel;
let progressCurrent = 0;
let progressTotal = 0;
let screenSnapshot = {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
};
function emitStatus(patch = {}) {
onStatusChange?.(runId, {
progressMode,
progressLabel: progressMode === "determinate" ? progressLabel : undefined,
progressCurrent: progressMode === "determinate" ? progressCurrent : undefined,
progressTotal: progressMode === "determinate" ? progressTotal : undefined,
elapsedMs: Math.max(0, Date.now() - startedAt),
stepIndex,
status: "running",
...patch,
});
}
function assertWriteAllowed(operation) {
if (permissionMode !== "observer") return;
throw new Error(`Observer mode: ${operation} is disabled. Switch to Confirm or Auto mode.`);
}
async function trackStep(label) {
stepIndex += 1;
const activityLabel = truncateActivityLabel(label);
emitStatus({
activityLabel,
currentStep: activityLabel,
});
}
async function refreshScreenSnapshot() {
if (typeof getScreenSnapshot === "function") {
try {
screenSnapshot = await getScreenSnapshot(sessionId);
} catch {
// fall back to output buffer text
}
}
return screenSnapshot;
}
async function waitForPromptWithRecovery(timeoutMs = 60000) {
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep("waitForPrompt");
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: "shell prompt", status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId).waitForAny(
shellPromptPatterns(),
timeoutMs,
() => Boolean(deps.isAborted?.()),
{ allowPreservedTailMatch: true },
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: "shell prompt", elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.("shell prompt", timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return -1;
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
async function waitForAnyWithRecovery(patterns, timeoutMs = 30000) {
const label = Array.isArray(patterns)
? patterns.map((pattern) => (pattern instanceof RegExp ? pattern.source : String(pattern))).join(" | ")
: String(patterns);
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep(`waitForAny ${truncateActivityLabel(label, 60)}`);
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: label, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId).waitForAny(
patterns,
timeoutMs,
() => Boolean(deps.isAborted?.()),
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: label, elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.(label, timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return -1;
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
async function waitForWithRecovery(pattern, timeoutMs = 30000, options = {}) {
const waitMethod = options.waitMethod || "waitFor";
const operationLabel = options.operationLabel || waitMethod;
const patternLabel = pattern instanceof RegExp ? pattern.source : String(pattern);
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep(`${operationLabel} ${truncateActivityLabel(patternLabel, 60)}`);
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: patternLabel, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId)[waitMethod](
pattern,
timeoutMs,
() => Boolean(deps.isAborted?.()),
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: patternLabel, elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.(patternLabel, timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return "";
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
const progressApi = {
start(label, total) {
progressMode = "determinate";
progressLabel = truncateActivityLabel(label || "Progress", 60);
progressTotal = Math.max(1, Number(total) || 1);
progressCurrent = 0;
emitStatus({
progressMode,
progressLabel,
progressCurrent,
progressTotal,
activityLabel: progressLabel,
});
},
set(current, detail) {
if (progressMode !== "determinate") return;
progressCurrent = Math.max(0, Math.min(progressTotal, Number(current) || 0));
const patch = {
progressCurrent,
progressTotal,
progressLabel,
};
if (detail !== undefined && detail !== null && detail !== "") {
patch.activityLabel = truncateActivityLabel(detail);
}
emitStatus(patch);
},
step(detail) {
if (progressMode !== "determinate") return;
progressCurrent = Math.min(progressTotal, progressCurrent + 1);
const patch = {
progressCurrent,
progressTotal,
progressLabel,
};
if (detail !== undefined && detail !== null && detail !== "") {
patch.activityLabel = truncateActivityLabel(detail);
}
emitStatus(patch);
},
done() {
if (progressMode !== "determinate") return;
progressCurrent = progressTotal;
emitStatus({
progressCurrent,
progressTotal,
progressLabel,
});
progressMode = "activity";
progressLabel = undefined;
progressCurrent = 0;
progressTotal = 0;
emitStatus({
progressMode: "activity",
progressLabel: undefined,
progressCurrent: undefined,
progressTotal: undefined,
});
},
};
const sessionApi = {
get connected() {
const meta = getSessionMeta?.(sessionId);
return Boolean(meta?.connected);
},
get hostname() {
return getSessionMeta?.(sessionId)?.hostname || "";
},
get username() {
return getSessionMeta?.(sessionId)?.username || "";
},
sleep(ms) {
const delay = Math.max(0, Number(ms) || 0);
return trackStep(`sleep ${delay}ms`).then(() => interruptibleSleep(delay, deps.isAborted));
},
async startLog(path) {
assertWriteAllowed("session.startLog");
await trackStep("startLog");
await startSessionLog?.(sessionId, path);
},
async stopLog() {
await trackStep("stopLog");
await stopSessionLog?.(sessionId);
},
async disconnect() {
assertWriteAllowed("session.disconnect");
await trackStep("disconnect");
await disconnectSession?.(sessionId);
},
};
const screenApi = {
async send(text) {
assertWriteAllowed("screen.send");
await waitIfPaused();
const payload = String(text ?? "");
await trackStep(`send: ${truncateActivityLabel(formatScriptInputForLog(payload), 60)}`);
appendLog(runId, `→ ${formatScriptInputForLog(payload)}`);
writeToSession(sessionId, payload, { automated: true });
},
async sendLine(text) {
assertWriteAllowed("screen.sendLine");
await waitIfPaused();
const line = String(text ?? "");
await trackStep(`sendLine: ${truncateActivityLabel(line, 60)}`);
appendLog(runId, `→ ${line}`);
writeToSession(sessionId, `${line}\r`, { automated: true });
},
waitFor(pattern, timeoutMs = 30000) {
return waitForWithRecovery(pattern, timeoutMs);
},
waitForText(text, timeoutMs = 30000) {
return waitForWithRecovery(text, timeoutMs, {
waitMethod: "waitForText",
operationLabel: "waitForText",
});
},
waitForRegex(pattern, timeoutMs = 30000) {
return waitForWithRecovery(pattern, timeoutMs, {
waitMethod: "waitForRegex",
operationLabel: "waitForRegex",
});
},
waitForPrompt(timeoutMs = 60000) {
return waitForPromptWithRecovery(timeoutMs);
},
async waitForAny(patterns, timeoutMs = 30000) {
return waitForAnyWithRecovery(patterns, timeoutMs);
},
async getText(startRow, endRow) {
await refreshScreenSnapshot();
const lines = screenSnapshot.lines || [];
const start = typeof startRow === "number" ? Math.max(0, startRow) : 0;
const end = typeof endRow === "number" ? Math.min(lines.length - 1, endRow) : lines.length - 1;
if (lines.length === 0) {
return getOutputBuffer(sessionId).getText();
}
return lines.slice(start, end + 1).join("\n");
},
get currentRow() {
return screenSnapshot.currentRow ?? 0;
},
get rows() {
return screenSnapshot.rows ?? 24;
},
get cols() {
return screenSnapshot.cols ?? 80;
},
async clear() {
assertWriteAllowed("screen.clear");
await trackStep("clear");
writeToSession(sessionId, "\x1b[2J\x1b[H", { automated: true });
},
};
const dialogApi = {
alert(message) {
return showDialog("alert", String(message ?? ""));
},
confirm(message) {
return showDialog("confirm", String(message ?? ""));
},
prompt(message, defaultValue = "") {
return showDialog("prompt", String(message ?? ""), String(defaultValue ?? ""));
},
};
const nct = {
session: sessionApi,
screen: screenApi,
dialog: dialogApi,
progress: progressApi,
version: deps.appVersion || "0.0.0",
sleep: sessionApi.sleep.bind(sessionApi),
log(message) {
stepIndex += 1;
emitStatus({
activityLabel: "log",
currentStep: "log",
});
appendLog(runId, String(message ?? ""));
},
};
async function waitIfPaused() {
while (isPaused?.()) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
onStatusChange?.(runId, { status: "paused", elapsedMs: Math.max(0, Date.now() - startedAt) });
await interruptibleSleep(100, deps.isAborted);
}
onStatusChange?.(runId, { status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
async function execute(source) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
const wrapped = wrapScriptSource(source);
const sandbox = {
nct,
console: {
log: (...args) => {
appendLog(runId, args.map((arg) => String(arg)).join(" "));
},
},
};
vm.createContext(sandbox);
const script = new vm.Script(wrapped, { filename: `script-${runId}.js` });
const result = script.runInContext(sandbox, { displayErrors: true });
if (result && typeof result.then === "function") {
await result;
}
}
return { execute, nct };
}
function interruptibleSleep(ms, isAborted) {
const delay = Math.max(0, Number(ms) || 0);
if (!isAborted) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (isAborted()) {
clearInterval(timer);
reject(new Error("Script stopped"));
return;
}
if (Date.now() - startedAt >= delay) {
clearInterval(timer);
resolve(undefined);
}
}, 50);
});
}
function formatScriptInputForLog(data) {
return String(data ?? "")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\x03/g, "^C")
.replace(/\x1b/g, "\\e");
}
module.exports = {
createScriptRuntime,
wrapScriptSource,
interruptibleSleep,
formatScriptInputForLog,
};