feat(terminal): add system manager side panel for processes, tmux, and Docker
Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
This commit is contained in:
@@ -1083,4 +1083,5 @@ module.exports = {
|
||||
// derives the preferred default key from findAllDefaultPrivateKeys()[0]).
|
||||
_findDefaultPrivateKey: findDefaultPrivateKey,
|
||||
_findAllDefaultPrivateKeys: findAllDefaultPrivateKeys,
|
||||
ensureMoshStatsConnection,
|
||||
};
|
||||
|
||||
302
electron/bridges/systemManager/dockerOps.cjs
Normal file
302
electron/bridges/systemManager/dockerOps.cjs
Normal file
@@ -0,0 +1,302 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function sanitizeDockerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function sanitizeContainerName(name) {
|
||||
const trimmed = String(name || "").trim().slice(0, 128);
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/[^a-zA-Z0-9_.-]/g, "") || null;
|
||||
}
|
||||
|
||||
function sanitizeImageRef(ref) {
|
||||
const trimmed = String(ref || "").trim().slice(0, 256);
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function parseDockerContainers(stdout) {
|
||||
const containers = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
containers.push({
|
||||
id: row.ID || row.Id || "",
|
||||
name: (row.Names || row.Name || "").replace(/^\//, ""),
|
||||
image: row.Image || "",
|
||||
status: row.Status || row.State || "",
|
||||
state: row.State || "",
|
||||
ports: row.Ports || "",
|
||||
createdAt: row.CreatedAt || row.Created || "",
|
||||
});
|
||||
} catch {
|
||||
// skip malformed line
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
function parseDockerStats(stdout) {
|
||||
const stats = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
stats.push({
|
||||
id: row.ID || row.Container || "",
|
||||
name: row.Name || "",
|
||||
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
|
||||
memUsage: row.MemUsage || "",
|
||||
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
|
||||
netIO: row.NetIO || "",
|
||||
blockIO: row.BlockIO || "",
|
||||
pids: Number(row.PIDs || row.Pids || 0) || 0,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function parseDockerImages(stdout) {
|
||||
const images = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
const repository = row.Repository || "";
|
||||
const tag = row.Tag || "";
|
||||
images.push({
|
||||
id: row.ID || row.Id || "",
|
||||
repository,
|
||||
tag,
|
||||
size: row.Size || "",
|
||||
createdAt: row.CreatedAt || row.CreatedSince || "",
|
||||
digest: row.Digest || "",
|
||||
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function summarizeImageInspect(info) {
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: info.Id,
|
||||
repoTags: info.RepoTags,
|
||||
repoDigests: info.RepoDigests,
|
||||
created: info.Created,
|
||||
size: info.Size,
|
||||
architecture: info.Architecture,
|
||||
os: info.Os,
|
||||
config: {
|
||||
env: info.Config?.Env,
|
||||
cmd: info.Config?.Cmd,
|
||||
entrypoint: info.Config?.Entrypoint,
|
||||
workingDir: info.Config?.WorkingDir,
|
||||
exposedPorts: info.Config?.ExposedPorts,
|
||||
labels: info.Config?.Labels,
|
||||
},
|
||||
rootfs: info.RootFS,
|
||||
history: Array.isArray(info.History) ? info.History.slice(0, 5) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeContainerInspect(info) {
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: info.Id,
|
||||
name: info.Name,
|
||||
image: info.Config?.Image,
|
||||
state: info.State,
|
||||
network: info.NetworkSettings,
|
||||
mounts: info.Mounts,
|
||||
env: info.Config?.Env,
|
||||
labels: info.Config?.Labels,
|
||||
created: info.Created,
|
||||
path: info.Path,
|
||||
args: info.Args,
|
||||
restartPolicy: info.HostConfig?.RestartPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function createDockerOpsApi({ execOnSession }) {
|
||||
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
|
||||
const cmd = `docker ${args}`;
|
||||
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
|
||||
if (!result.success) return result;
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listContainers(event, sessionId) {
|
||||
const result = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
"docker ps -a --format '{{json .}}'",
|
||||
12000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, containers: parseDockerContainers(result.stdout) };
|
||||
}
|
||||
|
||||
async function listImages(event, sessionId) {
|
||||
const result = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
"docker images --format '{{json .}}'",
|
||||
12000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, images: parseDockerImages(result.stdout) };
|
||||
}
|
||||
|
||||
async function getStats(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
|
||||
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
|
||||
const result = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
|
||||
15000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, stats: parseDockerStats(result.stdout) };
|
||||
}
|
||||
|
||||
async function inspectContainer(event, payload) {
|
||||
const { sessionId, containerId } = payload || {};
|
||||
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
return { success: true, inspect: summarizeContainerInspect(info) };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to parse inspect output" };
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectImage(event, payload) {
|
||||
const { sessionId, imageId } = payload || {};
|
||||
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
return { success: true, inspect: summarizeImageInspect(info) };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to parse image inspect output" };
|
||||
}
|
||||
}
|
||||
|
||||
async function containerAction(event, payload) {
|
||||
const { sessionId, containerId, action, newName } = payload || {};
|
||||
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
|
||||
switch (action) {
|
||||
case "start":
|
||||
return runDocker(event, sessionId, `start ${safeId}`);
|
||||
case "stop":
|
||||
return runDocker(event, sessionId, `stop ${safeId}`);
|
||||
case "restart":
|
||||
return runDocker(event, sessionId, `restart ${safeId}`);
|
||||
case "rm":
|
||||
return runDocker(event, sessionId, `rm -f ${safeId}`);
|
||||
case "pause":
|
||||
return runDocker(event, sessionId, `pause ${safeId}`);
|
||||
case "unpause":
|
||||
return runDocker(event, sessionId, `unpause ${safeId}`);
|
||||
case "kill":
|
||||
return runDocker(event, sessionId, `kill ${safeId}`);
|
||||
case "rename": {
|
||||
const next = sanitizeContainerName(newName);
|
||||
if (!next) return { success: false, error: "Invalid container name" };
|
||||
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Invalid container action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function imageAction(event, payload) {
|
||||
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
|
||||
if (!sessionId || !action) return { success: false, error: "Missing params" };
|
||||
|
||||
switch (action) {
|
||||
case "pull": {
|
||||
const ref = sanitizeImageRef(imageRef);
|
||||
if (!ref) return { success: false, error: "Missing image reference" };
|
||||
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
|
||||
}
|
||||
case "rm": {
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
if (!safeId) return { success: false, error: "Missing image id" };
|
||||
const forceFlag = force ? " -f" : "";
|
||||
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
|
||||
}
|
||||
case "prune": {
|
||||
const allFlag = all ? " -a" : "";
|
||||
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
|
||||
}
|
||||
case "tag": {
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const repo = sanitizeImageRef(repository);
|
||||
const tagName = String(tag || "").trim().slice(0, 128) || "latest";
|
||||
if (!safeId || !repo) return { success: false, error: "Missing params" };
|
||||
return runDocker(
|
||||
event,
|
||||
sessionId,
|
||||
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Invalid image action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listContainers,
|
||||
listImages,
|
||||
getStats,
|
||||
inspectContainer,
|
||||
inspectImage,
|
||||
containerAction,
|
||||
imageAction,
|
||||
parseDockerContainers,
|
||||
parseDockerStats,
|
||||
parseDockerImages,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDockerOpsApi,
|
||||
parseDockerContainers,
|
||||
parseDockerStats,
|
||||
parseDockerImages,
|
||||
};
|
||||
27
electron/bridges/systemManager/execConnHealth.cjs
Normal file
27
electron/bridges/systemManager/execConnHealth.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
|
||||
/** Best-effort check that an ssh2 Client transport is still usable. */
|
||||
function isSshConnAlive(conn) {
|
||||
if (!conn) return false;
|
||||
const sock = conn._sock;
|
||||
if (sock && sock.destroyed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when conn.exec failed because the underlying transport/channel is gone. */
|
||||
function isTransportExecError(message) {
|
||||
const msg = String(message || "").toLowerCase();
|
||||
return (
|
||||
msg.includes("not connected")
|
||||
|| msg.includes("connection lost")
|
||||
|| msg.includes("socket hang up")
|
||||
|| msg.includes("econnreset")
|
||||
|| msg.includes("closed")
|
||||
|| msg.includes("destroyed")
|
||||
|| msg.includes("channel open failure")
|
||||
|| msg.includes("unable to exec")
|
||||
|| msg.includes("no response")
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { isSshConnAlive, isTransportExecError };
|
||||
33
electron/bridges/systemManager/execConnHealth.test.cjs
Normal file
33
electron/bridges/systemManager/execConnHealth.test.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
|
||||
|
||||
describe("isSshConnAlive", () => {
|
||||
it("returns false for missing conn", () => {
|
||||
assert.equal(isSshConnAlive(null), false);
|
||||
});
|
||||
|
||||
it("returns false when socket is destroyed", () => {
|
||||
assert.equal(isSshConnAlive({ _sock: { destroyed: true } }), false);
|
||||
});
|
||||
|
||||
it("returns true when socket is alive", () => {
|
||||
assert.equal(isSshConnAlive({ _sock: { destroyed: false } }), true);
|
||||
assert.equal(isSshConnAlive({}), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransportExecError", () => {
|
||||
it("detects common ssh2 transport failures", () => {
|
||||
assert.equal(isTransportExecError("Not connected"), true);
|
||||
assert.equal(isTransportExecError("Channel open failure: open failed"), true);
|
||||
assert.equal(isTransportExecError("read ECONNRESET"), true);
|
||||
});
|
||||
|
||||
it("ignores unrelated command errors", () => {
|
||||
assert.equal(isTransportExecError("docker: no such container"), false);
|
||||
assert.equal(isTransportExecError("permission denied"), false);
|
||||
});
|
||||
});
|
||||
219
electron/bridges/systemManager/execOnSession.cjs
Normal file
219
electron/bridges/systemManager/execOnSession.cjs
Normal file
@@ -0,0 +1,219 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
|
||||
|
||||
function createExecOnSessionApi(ctx) {
|
||||
with (ctx) {
|
||||
/** Serialize remote exec per session to avoid SSH channel storms. */
|
||||
const execQueues = new Map();
|
||||
|
||||
function getSession(sessionId) {
|
||||
return sessions?.get?.(sessionId) ?? null;
|
||||
}
|
||||
|
||||
function enqueueExec(sessionId, task) {
|
||||
let state = execQueues.get(sessionId);
|
||||
if (!state) {
|
||||
state = { running: false, pending: [] };
|
||||
execQueues.set(sessionId, state);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
state.pending.push({ task, resolve });
|
||||
void drainExecQueue(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
async function drainExecQueue(sessionId) {
|
||||
const state = execQueues.get(sessionId);
|
||||
if (!state || state.running) return;
|
||||
state.running = true;
|
||||
while (state.pending.length > 0) {
|
||||
const job = state.pending.shift();
|
||||
if (!job) continue;
|
||||
try {
|
||||
const result = await job.task();
|
||||
job.resolve(result);
|
||||
} catch (err) {
|
||||
job.resolve({ success: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
state.running = false;
|
||||
if (state.pending.length === 0) {
|
||||
execQueues.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMoshCompanion(session, sessionId, event) {
|
||||
if (session?.type !== "mosh" || typeof ensureMoshStatsConnection !== "function") {
|
||||
return;
|
||||
}
|
||||
if (session.moshStatsConn && isSshConnAlive(session.moshStatsConn)) {
|
||||
return;
|
||||
}
|
||||
if (session.moshStatsConn && !isSshConnAlive(session.moshStatsConn)) {
|
||||
session.moshStatsConn = null;
|
||||
}
|
||||
if (!session.moshStatsConn && !session.moshStatsConnFailed) {
|
||||
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExecConnection(session, sessionId, event) {
|
||||
if (!session) return null;
|
||||
|
||||
await ensureMoshCompanion(session, sessionId, event);
|
||||
|
||||
const conn = session.conn || session.moshStatsConn;
|
||||
if (!conn) return null;
|
||||
|
||||
if (!isSshConnAlive(conn)) {
|
||||
if (session.moshStatsConn === conn) {
|
||||
session.moshStatsConn = null;
|
||||
await ensureMoshCompanion(session, sessionId, event);
|
||||
return session.conn || session.moshStatsConn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
function execOnConnection(conn, command, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let activeStream = null;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle({ success: false, error: "Command timeout" });
|
||||
try { if (activeStream) activeStream.close(); } catch { /* ignore */ }
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
settle({ success: false, error: err.message || String(err) });
|
||||
return;
|
||||
}
|
||||
activeStream = stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk) => { stdout += chunk.toString(); });
|
||||
if (stream.stderr) {
|
||||
stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
}
|
||||
stream.on("close", (code) => {
|
||||
settle({ success: true, stdout, stderr, code: code ?? 0 });
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
settle({ success: false, error: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) {
|
||||
if (session?.type === "et") {
|
||||
if (typeof execOnEtSession !== "function") {
|
||||
return { success: false, error: "ET command executor unavailable" };
|
||||
}
|
||||
return execOnEtSession(session, command, timeoutMs, {
|
||||
requireTrustedHost: true,
|
||||
knownHosts: session.etStatsAuth?.knownHosts,
|
||||
});
|
||||
}
|
||||
|
||||
const conn = await resolveExecConnection(session, sessionId, event);
|
||||
if (!conn) {
|
||||
if (session?.type === "mosh" && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
||||
return { success: false, pending: true, error: "Mosh handshake in progress" };
|
||||
}
|
||||
return { success: false, error: "Session not found or not connected" };
|
||||
}
|
||||
|
||||
const result = await execOnConnection(conn, command, timeoutMs);
|
||||
if (
|
||||
allowCompanionRetry
|
||||
&& !result.success
|
||||
&& session.moshStatsConn
|
||||
&& isTransportExecError(result.error)
|
||||
) {
|
||||
session.moshStatsConn = null;
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function execOnLocalMachine(command, timeoutMs) {
|
||||
const { execFile } = require("node:child_process");
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-Command", command],
|
||||
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && !stdout) {
|
||||
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
|
||||
return;
|
||||
}
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"sh",
|
||||
["-c", command],
|
||||
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && !stdout) {
|
||||
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
|
||||
return;
|
||||
}
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
execQueues.delete(sessionId);
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
if (session.protocol === "local" || session.type === "local") {
|
||||
return execOnLocalMachine(command, timeoutMs);
|
||||
}
|
||||
|
||||
if (session.conn || session.type === "mosh" || session.type === "et") {
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event);
|
||||
}
|
||||
|
||||
return { success: false, error: "Session not supported for system management" };
|
||||
}
|
||||
|
||||
async function execOnSession(event, sessionId, command, timeoutMs = 8000) {
|
||||
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs));
|
||||
}
|
||||
|
||||
function isLocalSession(sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
return !!(session?.protocol === "local" || session?.type === "local");
|
||||
}
|
||||
|
||||
return { execOnSession, execOnLocalMachine, isLocalSession, getSession };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createExecOnSessionApi };
|
||||
194
electron/bridges/systemManager/tmuxEnv.cjs
Normal file
194
electron/bridges/systemManager/tmuxEnv.cjs
Normal file
@@ -0,0 +1,194 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function wrapLoginShell(command) {
|
||||
const oneLine = String(command || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return `bash -lc ${JSON.stringify(oneLine)}`;
|
||||
}
|
||||
|
||||
function wrapShExec(command) {
|
||||
const oneLine = String(command || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return `exec sh -c ${JSON.stringify(oneLine)}`;
|
||||
}
|
||||
|
||||
function stripAnsi(text) {
|
||||
return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
}
|
||||
|
||||
function parseTmuxVersionString(text) {
|
||||
const match = stripAnsi(text).match(/tmux\s+(\d+)\.(\d+)([a-z0-9]*)/i);
|
||||
if (!match) {
|
||||
return { raw: stripAnsi(text).trim(), major: 0, minor: 0, patch: "" };
|
||||
}
|
||||
return {
|
||||
raw: match[0],
|
||||
major: Number(match[1]) || 0,
|
||||
minor: Number(match[2]) || 0,
|
||||
patch: match[3] || "",
|
||||
};
|
||||
}
|
||||
|
||||
function getListSessionsFormat(version) {
|
||||
const major = version?.major ?? 0;
|
||||
const minor = version?.minor ?? 0;
|
||||
|
||||
if (major < 2) return null;
|
||||
|
||||
const fields = ["#{session_name}", "#{session_windows}", "#{session_attached}"];
|
||||
|
||||
if (major >= 3 || (major === 2 && minor >= 1)) {
|
||||
fields.push("#{session_created}", "#{session_activity}");
|
||||
}
|
||||
|
||||
if (major > 3 || (major === 3 && minor >= 2)) {
|
||||
fields.push("#{session_group}");
|
||||
}
|
||||
|
||||
return fields.join("\\t");
|
||||
}
|
||||
|
||||
// Single-line script — multiline strings break when passed through bash -lc JSON quoting.
|
||||
const TMUX_DETECT_SCRIPT = [
|
||||
"uid=$(id -u 2>/dev/null || echo 0)",
|
||||
"echo \"__TMUX_VERSION__=$(tmux -V 2>/dev/null || true)\"",
|
||||
"echo \"__TMUX_BIN__=$(command -v tmux 2>/dev/null || which tmux 2>/dev/null || true)\"",
|
||||
"for d in \"${TMUX_TMPDIR:-/tmp}/tmux-$uid\" \"/tmp/tmux-$uid\"; do",
|
||||
"[ -d \"$d\" ] || continue",
|
||||
"for s in \"$d\"/*; do [ -S \"$s\" ] && echo \"__SOCKET__=$s\"; done",
|
||||
"done",
|
||||
].join("; ");
|
||||
|
||||
function parseDetectScriptOutput(stdout) {
|
||||
const info = {
|
||||
version: { raw: "", major: 0, minor: 0, patch: "" },
|
||||
binary: "",
|
||||
sockets: [],
|
||||
};
|
||||
|
||||
for (const line of stripAnsi(stdout).split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith("__TMUX_VERSION__=")) {
|
||||
info.version = parseTmuxVersionString(trimmed.slice("__TMUX_VERSION__=".length));
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("__TMUX_BIN__=")) {
|
||||
info.binary = trimmed.slice("__TMUX_BIN__=".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("__SOCKET__=")) {
|
||||
info.sockets.push(trimmed.slice("__SOCKET__=".length).trim());
|
||||
}
|
||||
}
|
||||
|
||||
info.sockets = [...new Set(info.sockets.filter(Boolean))];
|
||||
return info;
|
||||
}
|
||||
|
||||
function normalizeExecResult(result) {
|
||||
if (!result) return { success: false, error: "No exec result" };
|
||||
const stdout = stripAnsi(result.stdout || "");
|
||||
const stderr = stripAnsi(result.stderr || "");
|
||||
const combined = [stderr, stdout].filter(Boolean).join("\n").trim();
|
||||
if (!result.success && combined) {
|
||||
return {
|
||||
...result,
|
||||
success: true,
|
||||
stdout: combined,
|
||||
stderr,
|
||||
code: result.code ?? 1,
|
||||
};
|
||||
}
|
||||
return { ...result, stdout: combined || stdout, stderr };
|
||||
}
|
||||
|
||||
function buildTmuxInvocation(binary, socketPath, args) {
|
||||
const bin = binary || "tmux";
|
||||
const socketFlag = socketPath ? `-S ${shQuote(socketPath)} ` : "";
|
||||
return `${bin} ${socketFlag}${args}`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// tmux diagnostics that must never be mistaken for session names — a stale
|
||||
// socket makes `tmux ls 2>&1` print "error connecting to /tmp/tmux-0/default
|
||||
// (No such file or directory)", which the bare-name fallback below would
|
||||
// otherwise turn into a phantom session row.
|
||||
const TMUX_DIAGNOSTIC_LINE = /^(error connecting to|no server running|no current client|can't find|lost server|server exited|failed to connect|protocol version mismatch|open terminal failed|invalid option|usage:|unknown command)/i;
|
||||
|
||||
function isTmuxDiagnosticLine(line) {
|
||||
return TMUX_DIAGNOSTIC_LINE.test(String(line || "").trim());
|
||||
}
|
||||
|
||||
function parseListOutput(stdout) {
|
||||
const text = stripAnsi(stdout);
|
||||
const plain = [];
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
|
||||
if (match) {
|
||||
plain.push({
|
||||
name: match[1].trim(),
|
||||
windows: Number(match[2]) || 0,
|
||||
attached: /\battached\b/i.test(trimmed),
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length >= 4) {
|
||||
plain.push({
|
||||
name: parts[0].trim(),
|
||||
windows: Number(parts[1]) || 0,
|
||||
attached: parts[2] === "1",
|
||||
created: Number(parts[3]) || 0,
|
||||
activity: parts[4] || "",
|
||||
group: parts[5] || "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (parts.length === 1 && !trimmed.includes(":")) {
|
||||
plain.push({
|
||||
name: trimmed,
|
||||
windows: 0,
|
||||
attached: false,
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return plain;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shQuote,
|
||||
wrapLoginShell,
|
||||
wrapShExec,
|
||||
stripAnsi,
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
buildTmuxInvocation,
|
||||
parseListOutput,
|
||||
isTmuxDiagnosticLine,
|
||||
TMUX_DIAGNOSTIC_LINE,
|
||||
};
|
||||
272
electron/bridges/systemManager/tmuxEnv.test.cjs
Normal file
272
electron/bridges/systemManager/tmuxEnv.test.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
parseListOutput,
|
||||
wrapLoginShell,
|
||||
} = require("./tmuxEnv.cjs");
|
||||
|
||||
test("parseTmuxVersionString handles tmux 3.0a", () => {
|
||||
const v = parseTmuxVersionString("tmux 3.0a");
|
||||
assert.equal(v.major, 3);
|
||||
assert.equal(v.minor, 0);
|
||||
assert.equal(v.patch, "a");
|
||||
});
|
||||
|
||||
test("wrapLoginShell flattens multiline scripts", () => {
|
||||
const wrapped = wrapLoginShell("echo one\necho two");
|
||||
assert.ok(!wrapped.includes("\\n"));
|
||||
assert.ok(wrapped.includes("; echo two"));
|
||||
});
|
||||
|
||||
test("parseListOutput parses default tmux ls line", () => {
|
||||
const sample = "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)\n";
|
||||
const sessions = parseListOutput(sample);
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].name, "test-session");
|
||||
assert.equal(sessions[0].windows, 1);
|
||||
});
|
||||
|
||||
test("getListSessionsFormat omits session_group before tmux 3.2", () => {
|
||||
const fmt = getListSessionsFormat({ major: 3, minor: 0 });
|
||||
assert.ok(fmt.includes("session_name"));
|
||||
assert.ok(!fmt.includes("session_group"));
|
||||
});
|
||||
|
||||
test("parseDetectScriptOutput reads version and sockets", () => {
|
||||
const stdout = [
|
||||
"__TMUX_VERSION__=tmux 3.0a",
|
||||
"__TMUX_BIN__=/usr/bin/tmux",
|
||||
"__SOCKET__=/tmp/tmux-0/default",
|
||||
].join("\n");
|
||||
const parsed = parseDetectScriptOutput(stdout);
|
||||
assert.equal(parsed.version.major, 3);
|
||||
assert.equal(parsed.binary, "/usr/bin/tmux");
|
||||
assert.deepEqual(parsed.sockets, ["/tmp/tmux-0/default"]);
|
||||
});
|
||||
|
||||
test("parseListOutput ignores tmux diagnostic lines", () => {
|
||||
const sample = [
|
||||
"error connecting to /tmp/tmux-0/default (No such file or directory)",
|
||||
"no server running on /private/tmp/tmux-501/default",
|
||||
"can't find session: missing",
|
||||
"test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
|
||||
].join("\n");
|
||||
const sessions = parseListOutput(sample);
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].name, "test-session");
|
||||
});
|
||||
|
||||
test("parseListOutput returns nothing for a lone stale-socket error", () => {
|
||||
const sessions = parseListOutput(
|
||||
"error connecting to /tmp/tmux-0/default (No such file or directory)\n",
|
||||
);
|
||||
assert.deepEqual(sessions, []);
|
||||
});
|
||||
|
||||
test("isNoTmuxServerMessage matches stale-socket connect errors", () => {
|
||||
const { isNoTmuxServerMessage } = require("./tmuxOps.cjs");
|
||||
assert.equal(
|
||||
isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 1),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNoTmuxServerMessage("no server running on /tmp/tmux-501/default", 1), true);
|
||||
assert.equal(isNoTmuxServerMessage("test-session: 1 windows", 1), false);
|
||||
assert.equal(isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 0), false);
|
||||
});
|
||||
|
||||
test("mutating tmux commands execute exactly once on silent success", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const executed = [];
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
executed.push(command);
|
||||
// Silent success, the normal result for kill-session/send-keys/split-window.
|
||||
return { success: true, stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.tmuxAction(null, {
|
||||
sessionId: "s1",
|
||||
action: "killSession",
|
||||
sessionName: "demo",
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
const killRuns = executed.filter((cmd) => cmd.includes("kill-session"));
|
||||
assert.equal(killRuns.length, 1, `kill-session ran ${killRuns.length} times: ${executed.join(" | ")}`);
|
||||
});
|
||||
|
||||
test("parseTmuxWindowsPlain parses default tmux list-windows output", () => {
|
||||
const { parseTmuxWindowsPlain } = require("./tmuxOps.cjs");
|
||||
const sample = [
|
||||
"0: bash* (2 panes) [160x40] [b33d,1]",
|
||||
"1: zsh (1 pane) [160x40] [b33d,2]",
|
||||
].join("\n");
|
||||
const windows = parseTmuxWindowsPlain(sample);
|
||||
assert.equal(windows.length, 2);
|
||||
assert.equal(windows[0].name, "bash");
|
||||
assert.equal(windows[0].panes, 2);
|
||||
assert.equal(windows[0].active, true);
|
||||
assert.equal(windows[1].name, "zsh");
|
||||
});
|
||||
|
||||
test("parseTmuxWindows falls back to plain output when -F tabs are missing", () => {
|
||||
const { parseTmuxWindows } = require("./tmuxOps.cjs");
|
||||
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
|
||||
assert.equal(windows.length, 1);
|
||||
assert.equal(windows[0].panes, 2);
|
||||
});
|
||||
|
||||
test("parseTmuxWindows reads list-windows output from stderr", () => {
|
||||
const { parseTmuxWindows } = require("./tmuxOps.cjs");
|
||||
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
|
||||
assert.equal(windows.length, 1);
|
||||
assert.equal(windows[0].name, "main");
|
||||
});
|
||||
|
||||
test("list-windows tries alternate socket when default returns empty", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "__TMUX_VERSION__=tmux 3.0a\n__SOCKET__=/tmp/tmux-0/custom\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (command.includes("-S '/tmp/tmux-0/custom'") && command.includes("list-windows")) {
|
||||
return { success: true, stdout: "0: main* (2 panes) [80x24]", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return { success: true, stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].panes, 2);
|
||||
});
|
||||
|
||||
test("parseTmuxWindowsAllPlain parses list-windows -a default output", () => {
|
||||
const { parseTmuxWindowsAllPlain } = require("./tmuxOps.cjs");
|
||||
const sample = [
|
||||
"test-session: 0: bash* (2 panes) [80x24]",
|
||||
"test-session: 1: zsh (1 pane) [80x24]",
|
||||
"other: 0: vim (1 pane) [80x24]",
|
||||
].join("\n");
|
||||
const windows = parseTmuxWindowsAllPlain(sample, "test-session");
|
||||
assert.equal(windows.length, 2);
|
||||
assert.equal(windows[0].name, "bash");
|
||||
assert.equal(windows[1].index, 1);
|
||||
});
|
||||
|
||||
test("list-windows falls back to list-windows -a when -t target misses", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("for d in") || command.includes("__TMUX_")) {
|
||||
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows -a")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "test-session: 0: main* (2 panes) [80x24]\ntest-session: 1: aux (1 pane) [80x24]",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return { success: true, stdout: "can't find session: test-session", stderr: "", code: 1 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 2);
|
||||
});
|
||||
|
||||
test("list-windows parses output delivered on stderr", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_") || command.includes("for d in")) {
|
||||
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "0: remote* (2 panes) [80x24]\n",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].name, "remote");
|
||||
});
|
||||
|
||||
test("parseTmuxPanes splits literal \\t when remote printf fails", () => {
|
||||
const { parseTmuxPanes } = require("./tmuxOps.cjs");
|
||||
const sample = "0\\tRainYun-0tWTeTRw\\tbash\\t\\t\\t2232702\\t80\\t24";
|
||||
const panes = parseTmuxPanes(sample);
|
||||
assert.equal(panes.length, 1);
|
||||
assert.equal(panes[0].title, "RainYun-0tWTeTRw");
|
||||
assert.equal(panes[0].command, "bash");
|
||||
assert.equal(panes[0].pid, 2232702);
|
||||
assert.equal(panes[0].width, 80);
|
||||
assert.equal(panes[0].height, 24);
|
||||
});
|
||||
|
||||
test("parseTmuxPanesPlain parses default list-panes output", () => {
|
||||
const { parseTmuxPanesPlain } = require("./tmuxOps.cjs");
|
||||
const panes = parseTmuxPanesPlain("0: [80x24]\n1: [80x24] (active)");
|
||||
assert.equal(panes.length, 2);
|
||||
assert.equal(panes[1].active, true);
|
||||
});
|
||||
|
||||
test("list-windows routes tab separators through printf (tmux does not expand \\t in -F)", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const commands = [];
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
commands.push(command);
|
||||
if (command.includes("list-windows")) {
|
||||
// Real tab characters, as printf would produce on the remote host.
|
||||
return { success: true, stdout: "0\tmain\t2\t1\tlayout", stderr: "", code: 0 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "demo" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].name, "main");
|
||||
assert.equal(result.windows[0].panes, 2);
|
||||
const listCmd = commands.find((cmd) => cmd.includes("list-windows"));
|
||||
assert.ok(listCmd.includes("$(printf '"), `expected printf-wrapped format, got: ${listCmd}`);
|
||||
});
|
||||
|
||||
test("normalizeExecResult keeps stdout from failed ET-style exec", () => {
|
||||
const normalized = normalizeExecResult({
|
||||
success: false,
|
||||
error: "Command failed",
|
||||
stdout: "",
|
||||
stderr: "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
|
||||
code: 1,
|
||||
});
|
||||
assert.equal(normalized.success, true);
|
||||
assert.equal(parseListOutput(normalized.stdout).length, 1);
|
||||
});
|
||||
803
electron/bridges/systemManager/tmuxOps.cjs
Normal file
803
electron/bridges/systemManager/tmuxOps.cjs
Normal file
@@ -0,0 +1,803 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const {
|
||||
shQuote,
|
||||
wrapLoginShell,
|
||||
wrapShExec,
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
buildTmuxInvocation,
|
||||
parseListOutput,
|
||||
isTmuxDiagnosticLine,
|
||||
} = require("./tmuxEnv.cjs");
|
||||
|
||||
function shQuoteLocal(str) {
|
||||
return shQuote(str);
|
||||
}
|
||||
|
||||
function tmuxTarget(sessionName, windowIndex, paneIndex) {
|
||||
const sessionRef = shQuoteLocal(sessionName);
|
||||
if (windowIndex === undefined || windowIndex === null) return sessionRef;
|
||||
const win = Number(windowIndex);
|
||||
if (paneIndex === undefined || paneIndex === null) return `${sessionRef}:${win}`;
|
||||
return `${sessionRef}:${win}.${Number(paneIndex)}`;
|
||||
}
|
||||
|
||||
function sanitizeNewSessionName(name) {
|
||||
const trimmed = String(name || "").trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.slice(0, 64);
|
||||
}
|
||||
|
||||
function parseTmuxSessions(stdout) {
|
||||
const sessions = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length < 4) continue;
|
||||
sessions.push({
|
||||
name: parts[0],
|
||||
windows: Number(parts[1]) || 0,
|
||||
attached: parts[2] === "1",
|
||||
created: Number(parts[3]) || 0,
|
||||
activity: parts[4] || "",
|
||||
group: parts[5] || "",
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/** Fallback parser for default `tmux list-sessions` / `tmux ls` output. */
|
||||
function parseTmuxSessionsPlain(stdout) {
|
||||
const sessions = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
|
||||
if (!match) continue;
|
||||
sessions.push({
|
||||
name: match[1].trim(),
|
||||
windows: Number(match[2]) || 0,
|
||||
attached: /\battached\b/i.test(trimmed),
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function parseTmuxSessionNames(stdout) {
|
||||
return (stdout || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({
|
||||
name,
|
||||
windows: 0,
|
||||
attached: false,
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
}));
|
||||
}
|
||||
|
||||
function isNoTmuxServerMessage(text, code) {
|
||||
if (code !== 1) return false;
|
||||
const msg = String(text || "").toLowerCase();
|
||||
if (msg.includes("no server running")) return true;
|
||||
// Stale socket file: "error connecting to /tmp/tmux-0/default (No such file or directory)"
|
||||
return msg.includes("error connecting to") && msg.includes("no such file or directory");
|
||||
}
|
||||
|
||||
/** Split tmux -F rows on real tabs or literal `\t` when remote printf fails. */
|
||||
function splitTmuxFields(line) {
|
||||
const text = String(line || "");
|
||||
if (text.includes("\t")) return text.split("\t");
|
||||
if (text.includes("\\t")) return text.split("\\t");
|
||||
return [text];
|
||||
}
|
||||
|
||||
/** Default `tmux list-windows` lines, e.g. `0: bash* (2 panes) [80x24]`. */
|
||||
function parseTmuxWindowsPlain(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
let match = trimmed.match(/^(\d+):\s*(.+?)(\*)?\s+\((\d+)\s+panes?\)/i);
|
||||
if (match) {
|
||||
windows.push({
|
||||
index: Number(match[1]),
|
||||
name: match[2].trim(),
|
||||
panes: Number(match[4]) || 0,
|
||||
active: match[3] === "*",
|
||||
layout: "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match = trimmed.match(/^(\d+):\s*(.*?)(\*)?(?:\s+\[[^\]]+\]|\s*$)/);
|
||||
if (!match) continue;
|
||||
windows.push({
|
||||
index: Number(match[1]),
|
||||
name: match[2].trim(),
|
||||
panes: 0,
|
||||
active: match[3] === "*",
|
||||
layout: "",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function parseTmuxWindows(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const parts = splitTmuxFields(trimmed);
|
||||
if (parts.length < 4) continue;
|
||||
windows.push({
|
||||
index: Number(parts[0]),
|
||||
name: parts[1],
|
||||
panes: Number(parts[2]) || 0,
|
||||
active: parts[3] === "1",
|
||||
layout: parts[4] || "",
|
||||
});
|
||||
}
|
||||
if (windows.length > 0) return windows;
|
||||
return parseTmuxWindowsPlain(stdout);
|
||||
}
|
||||
|
||||
function parseTmuxWindowsAll(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const parts = splitTmuxFields(trimmed);
|
||||
if (parts.length < 5) continue;
|
||||
windows.push({
|
||||
session: parts[0].trim(),
|
||||
index: Number(parts[1]),
|
||||
name: parts[2],
|
||||
panes: Number(parts[3]) || 0,
|
||||
active: parts[4] === "1",
|
||||
layout: parts[5] || "",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** Plain `tmux list-windows -a` lines, e.g. `test-session: 0: bash* (2 panes)`. */
|
||||
function parseTmuxWindowsAllPlain(stdout, sessionName) {
|
||||
const name = String(sessionName || "").trim();
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+):\s*(.+)$/);
|
||||
if (!match || match[1].trim() !== name) continue;
|
||||
const parsed = parseTmuxWindowsPlain(`${match[2]}: ${match[3]}`);
|
||||
if (parsed.length > 0) windows.push(parsed[0]);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function filterWindowsForSession(rows, sessionName) {
|
||||
const name = String(sessionName || "").trim();
|
||||
return rows
|
||||
.filter((row) => row.session === name)
|
||||
.map(({ session, ...window }) => window);
|
||||
}
|
||||
|
||||
function parseTmuxPaneRow(parts) {
|
||||
if (parts.length < 5) return null;
|
||||
return {
|
||||
index: Number(parts[0]),
|
||||
title: parts[1] || "",
|
||||
command: parts[2] || "",
|
||||
active: parts[3] === "1" || (parts.length >= 7 && parts[parts.length - 4] === "1"),
|
||||
pid: Number(parts[4]) || (parts.length >= 7 ? Number(parts[parts.length - 3]) : 0) || 0,
|
||||
width: Number(parts[parts.length >= 7 ? parts.length - 2 : 5]) || 0,
|
||||
height: Number(parts[parts.length >= 7 ? parts.length - 1 : 6]) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default `tmux list-panes` lines, e.g. `0: [80x24]` or `1: [80x24] (active)`. */
|
||||
function parseTmuxPanesPlain(stdout) {
|
||||
const panes = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^(\d+):\s*\[(\d+)x(\d+)\]/);
|
||||
if (!match) continue;
|
||||
panes.push({
|
||||
index: Number(match[1]),
|
||||
title: "",
|
||||
command: "",
|
||||
active: /\bactive\b/i.test(trimmed) || trimmed.includes("*"),
|
||||
pid: 0,
|
||||
width: Number(match[2]) || 0,
|
||||
height: Number(match[3]) || 0,
|
||||
});
|
||||
}
|
||||
return panes;
|
||||
}
|
||||
|
||||
function parseTmuxPanes(stdout) {
|
||||
const panes = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const row = parseTmuxPaneRow(splitTmuxFields(trimmed));
|
||||
if (row) panes.push(row);
|
||||
}
|
||||
if (panes.length > 0) return panes;
|
||||
return parseTmuxPanesPlain(stdout);
|
||||
}
|
||||
|
||||
function parseTmuxClients(stdout, sessionName) {
|
||||
const clients = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length < 4) continue;
|
||||
if (sessionName && parts[3] !== sessionName) continue;
|
||||
clients.push({
|
||||
name: parts[0],
|
||||
tty: parts[1],
|
||||
activity: parts[2],
|
||||
session: parts[3],
|
||||
});
|
||||
}
|
||||
return clients;
|
||||
}
|
||||
|
||||
// Legacy export kept for tests — prefer getListSessionsFormat(version).
|
||||
const TMUX_LIST_SESSIONS_FMT = getListSessionsFormat({ major: 3, minor: 0 });
|
||||
|
||||
const TMUX_LIST_WINDOWS_FMT = "#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
|
||||
const TMUX_LIST_ALL_WINDOWS_FMT = "#{session_name}\\t#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
|
||||
const TMUX_LIST_PANES_FMT = "#{pane_index}\\t#{pane_title}\\t#{pane_current_command}\\t#{pane_active}\\t#{pane_pid}\\t#{pane_width}\\t#{pane_height}";
|
||||
const TMUX_LIST_CLIENTS_FMT = "#{client_name}\\t#{client_tty}\\t#{client_activity}\\t#{client_session}";
|
||||
|
||||
/**
|
||||
* tmux does NOT expand \t inside -F format strings — quoting the format
|
||||
* directly emits a literal backslash-t and the tab-split parsers see one
|
||||
* giant field. Route the format through printf on the remote side so the
|
||||
* separators become real tab characters.
|
||||
*/
|
||||
function tmuxFormatArg(format) {
|
||||
return `"$(printf '${format}')"`;
|
||||
}
|
||||
|
||||
function createTmuxOpsApi({ execOnSession }) {
|
||||
/** @type {Map<string, { version: object, binary: string, sockets: string[], detectedAt: number }>} */
|
||||
const envCache = new Map();
|
||||
const ENV_TTL_MS = 60_000;
|
||||
|
||||
async function execShell(event, sessionId, script, timeoutMs = 8000, options = {}) {
|
||||
// retryOnEmptyOutput exists for READ commands where empty stdout means the
|
||||
// wrapper swallowed the output. Mutating commands (kill-session, send-keys,
|
||||
// split-window…) succeed silently — retrying them re-executes the mutation,
|
||||
// so they must pass retryOnEmptyOutput: false.
|
||||
const { retryOnEmptyOutput = true } = options;
|
||||
const attempts = [
|
||||
wrapShExec(script),
|
||||
wrapLoginShell(script),
|
||||
script,
|
||||
];
|
||||
let last = { success: false, error: "No exec result", stdout: "", stderr: "" };
|
||||
for (const cmd of attempts) {
|
||||
last = normalizeExecResult(await execOnSession(event, sessionId, cmd, timeoutMs));
|
||||
if (last.success && (!retryOnEmptyOutput || String(last.stdout || "").trim())) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function detectTmuxEnv(event, sessionId, force = false) {
|
||||
const cached = envCache.get(sessionId);
|
||||
if (!force && cached && Date.now() - cached.detectedAt < ENV_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const env = {
|
||||
version: { raw: "", major: 0, minor: 0, patch: "" },
|
||||
binary: "tmux",
|
||||
sockets: [],
|
||||
preferredSocket: cached?.preferredSocket ?? null,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
|
||||
const versionResult = await execShell(event, sessionId, "tmux -V 2>&1", 5000);
|
||||
if (versionResult.success && versionResult.stdout) {
|
||||
env.version = parseTmuxVersionString(versionResult.stdout);
|
||||
}
|
||||
|
||||
const binResult = await execShell(event, sessionId, "command -v tmux 2>/dev/null || which tmux 2>/dev/null", 5000);
|
||||
if (binResult.success && binResult.stdout) {
|
||||
const bin = binResult.stdout.split("\n").map((l) => l.trim()).find(Boolean);
|
||||
if (bin) env.binary = bin;
|
||||
}
|
||||
|
||||
const socketResult = await execShell(
|
||||
event,
|
||||
sessionId,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
8000,
|
||||
);
|
||||
if (socketResult.success) {
|
||||
const parsed = parseDetectScriptOutput(socketResult.stdout);
|
||||
if (parsed.version.raw) env.version = parsed.version;
|
||||
if (parsed.binary) env.binary = parsed.binary;
|
||||
env.sockets = parsed.sockets;
|
||||
}
|
||||
|
||||
envCache.set(sessionId, env);
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildSocketOrder(env) {
|
||||
const order = [];
|
||||
if (env.preferredSocket) order.push(env.preferredSocket);
|
||||
order.push(null);
|
||||
for (const socket of env.sockets || []) {
|
||||
if (socket && !order.includes(socket)) order.push(socket);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function rememberPreferredSocket(sessionId, env, socketPath) {
|
||||
const next = {
|
||||
...env,
|
||||
preferredSocket: socketPath ?? env.preferredSocket ?? null,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
envCache.set(sessionId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function queryTmuxRows(event, sessionId, buildArgVariants, parseRows) {
|
||||
const env = await detectTmuxEnv(event, sessionId);
|
||||
let lastError = "Cannot read tmux data";
|
||||
let lastOutput = "";
|
||||
const tried = [];
|
||||
|
||||
for (const socketPath of buildSocketOrder(env)) {
|
||||
for (const args of buildArgVariants()) {
|
||||
const cmd = buildTmuxInvocation(env.binary, socketPath, args);
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
const rows = parseRows(output);
|
||||
if (rows.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, rows };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: {
|
||||
lastOutput,
|
||||
tried: tried.slice(-8),
|
||||
sockets: buildSocketOrder(env),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function execTmux(event, sessionId, args, timeoutMs = 8000, options = {}) {
|
||||
const env = options.env || await detectTmuxEnv(event, sessionId);
|
||||
const shellOptions = { retryOnEmptyOutput: options.retryOnEmptyOutput ?? true };
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(options, "socketPath")) {
|
||||
const cmd = buildTmuxInvocation(env.binary, options.socketPath, args);
|
||||
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
|
||||
return { ...result, socketPath: options.socketPath ?? null, env };
|
||||
}
|
||||
|
||||
const attempts = buildSocketOrder(env);
|
||||
let lastResult = null;
|
||||
for (const socketPathResolved of attempts) {
|
||||
const cmd = buildTmuxInvocation(env.binary, socketPathResolved, args);
|
||||
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
|
||||
lastResult = result;
|
||||
if (!result.success) continue;
|
||||
|
||||
const combined = `${result.stderr || ""}\n${result.stdout || ""}`;
|
||||
if (isNoTmuxServerMessage(combined, result.code)) continue;
|
||||
const hasOutput = Boolean((result.stdout || "").trim());
|
||||
if (hasOutput || result.code === 0) {
|
||||
if (hasOutput) rememberPreferredSocket(sessionId, env, socketPathResolved);
|
||||
return { ...result, socketPath: socketPathResolved, env };
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult || { success: false, error: "tmux command failed" };
|
||||
}
|
||||
|
||||
async function runTmux(event, sessionId, args, timeoutMs = 8000) {
|
||||
// No empty-output retry here: runTmux carries every mutating tmux command,
|
||||
// and list-* commands routed through it may legitimately print nothing.
|
||||
const result = await execTmux(event, sessionId, args, timeoutMs, { retryOnEmptyOutput: false });
|
||||
if (!result.success) return result;
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: (result.stderr || result.stdout || "").trim() || `tmux exited with code ${result.code}`,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listSessions(event, sessionId) {
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
|
||||
const buildListCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
const cmds = [inv("list-sessions 2>&1"), inv("list-sessions")];
|
||||
const format = getListSessionsFormat(env.version);
|
||||
if (format) cmds.push(inv(`list-sessions -F ${tmuxFormatArg(format)}`));
|
||||
cmds.push(inv("list-sessions -F '#{session_name}'"));
|
||||
return cmds;
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildListCommands(env.binary, socketPath)) {
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output;
|
||||
if (!result.success) continue;
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
const sessions = parseListOutput(output);
|
||||
if (sessions.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return {
|
||||
success: true,
|
||||
sessions,
|
||||
tmuxVersion: env.version.raw || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoTmuxServerMessage(lastOutput, 1)) {
|
||||
return { success: true, sessions: [], tmuxVersion: env.version.raw || undefined };
|
||||
}
|
||||
|
||||
const diag = [
|
||||
env.version.raw || "tmux version unknown",
|
||||
env.binary ? `bin=${env.binary}` : null,
|
||||
env.sockets.length ? `sockets=${env.sockets.join(",")}` : "sockets=none",
|
||||
lastOutput ? `last=${lastOutput.slice(0, 240)}` : "last=empty",
|
||||
].filter(Boolean).join("; ");
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot list tmux sessions (${diag})`,
|
||||
tmuxVersion: env.version.raw || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(event, payload) {
|
||||
const { sessionId, name, command } = payload || {};
|
||||
if (!sessionId || !name) return { success: false, error: "Missing sessionId or name" };
|
||||
const safeName = sanitizeNewSessionName(name);
|
||||
if (!safeName) return { success: false, error: "Invalid session name" };
|
||||
|
||||
envCache.delete(sessionId);
|
||||
const result = await runTmux(event, sessionId, `new-session -d -s ${shQuoteLocal(safeName)}`, 8000);
|
||||
if (!result.success) return { success: false, error: result.error || result.stderr };
|
||||
const cmd = String(command || "").trim();
|
||||
if (cmd) {
|
||||
const sendResult = await runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`send-keys -t ${shQuoteLocal(safeName)} ${shQuoteLocal(cmd)} C-m`,
|
||||
8000,
|
||||
);
|
||||
if (!sendResult.success) {
|
||||
return { success: false, error: sendResult.error || sendResult.stderr };
|
||||
}
|
||||
}
|
||||
envCache.delete(sessionId);
|
||||
return { success: true, name: safeName };
|
||||
}
|
||||
|
||||
async function listWindows(event, payload) {
|
||||
const { sessionId, sessionName } = payload || {};
|
||||
if (!sessionId || !sessionName) return { success: false, error: "Missing params" };
|
||||
const name = String(sessionName).trim();
|
||||
const target = tmuxTarget(name);
|
||||
const targetExact = tmuxTarget(`=${name}`);
|
||||
|
||||
// Mirror listSessions: force-refresh env and walk the same socket order.
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
let lastError = "Cannot list tmux windows";
|
||||
const tried = [];
|
||||
|
||||
const buildCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
return [
|
||||
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -t ${target} 2>&1`),
|
||||
inv(`list-windows -t ${targetExact} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -t ${targetExact} 2>&1`),
|
||||
inv(`list-windows -a -F ${tmuxFormatArg(TMUX_LIST_ALL_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -a 2>&1`),
|
||||
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)}`),
|
||||
inv(`list-windows -t ${target}`),
|
||||
];
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildCommands(env.binary, socketPath)) {
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
let windows = [];
|
||||
if (cmd.includes("list-windows -a")) {
|
||||
const formatted = parseTmuxWindowsAll(output);
|
||||
windows = formatted.length > 0
|
||||
? filterWindowsForSession(formatted, name)
|
||||
: parseTmuxWindowsAllPlain(output, name);
|
||||
} else {
|
||||
windows = parseTmuxWindows(output);
|
||||
}
|
||||
|
||||
if (windows.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, windows };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
|
||||
};
|
||||
}
|
||||
|
||||
async function listPanes(event, payload) {
|
||||
const { sessionId, sessionName, windowIndex } = payload || {};
|
||||
if (!sessionId || !sessionName || windowIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const name = String(sessionName).trim();
|
||||
const target = tmuxTarget(name, windowIndex);
|
||||
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
let lastError = "Cannot list tmux panes";
|
||||
const tried = [];
|
||||
|
||||
const buildCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
return [
|
||||
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)} 2>&1`),
|
||||
inv(`list-panes -t ${target} 2>&1`),
|
||||
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)}`),
|
||||
inv(`list-panes -t ${target}`),
|
||||
];
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildCommands(env.binary, socketPath)) {
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
const panes = parseTmuxPanes(output);
|
||||
if (panes.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, panes };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
|
||||
};
|
||||
}
|
||||
|
||||
async function listClients(event, payload) {
|
||||
const { sessionId, sessionName } = payload || {};
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
const result = await runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`list-clients -F ${tmuxFormatArg(TMUX_LIST_CLIENTS_FMT)}`,
|
||||
8000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
clients: parseTmuxClients(result.stdout, sessionName || undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function tmuxAction(event, payload) {
|
||||
const { sessionId, action } = payload || {};
|
||||
if (!sessionId || !action) return { success: false, error: "Missing sessionId or action" };
|
||||
|
||||
switch (action) {
|
||||
case "killSession": {
|
||||
const { sessionName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
return runTmux(event, sessionId, `kill-session -t ${tmuxTarget(sessionName)}`, 8000);
|
||||
}
|
||||
case "renameSession": {
|
||||
const { sessionName, newName } = payload;
|
||||
const next = sanitizeNewSessionName(newName);
|
||||
if (!sessionName || !next) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`rename-session -t ${tmuxTarget(sessionName)} ${shQuote(next)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "detachSession": {
|
||||
const { sessionName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
return runTmux(event, sessionId, `detach-client -s ${tmuxTarget(sessionName)}`, 8000);
|
||||
}
|
||||
case "createWindow": {
|
||||
const { sessionName, windowName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
const nameArg = windowName && String(windowName).trim()
|
||||
? ` -n ${shQuote(String(windowName).trim().slice(0, 64))}`
|
||||
: "";
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`new-window -t ${tmuxTarget(sessionName)}${nameArg}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killWindow": {
|
||||
const { sessionName, windowIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`kill-window -t ${tmuxTarget(sessionName, windowIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "renameWindow": {
|
||||
const { sessionName, windowIndex, newName } = payload;
|
||||
const next = String(newName || "").trim().slice(0, 64);
|
||||
if (!sessionName || windowIndex === undefined || !next) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`rename-window -t ${tmuxTarget(sessionName, windowIndex)} ${shQuote(next)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killPane": {
|
||||
const { sessionName, windowIndex, paneIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`kill-pane -t ${tmuxTarget(sessionName, windowIndex, paneIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "splitPane": {
|
||||
const { sessionName, windowIndex, paneIndex, direction } = payload;
|
||||
if (!sessionName || windowIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const flag = direction === "vertical" ? "-v" : "-h";
|
||||
const target = paneIndex !== undefined && paneIndex !== null
|
||||
? tmuxTarget(sessionName, windowIndex, paneIndex)
|
||||
: tmuxTarget(sessionName, windowIndex);
|
||||
return runTmux(event, sessionId, `split-window -t ${target} ${flag}`, 8000);
|
||||
}
|
||||
case "sendKeys": {
|
||||
const { sessionName, windowIndex, paneIndex, keys, enter } = payload;
|
||||
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const keyText = String(keys ?? "");
|
||||
const enterSuffix = enter !== false ? " C-m" : "";
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`send-keys -t ${tmuxTarget(sessionName, windowIndex, paneIndex)} ${shQuote(keyText)}${enterSuffix}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "selectWindow": {
|
||||
const { sessionName, windowIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`select-window -t ${tmuxTarget(sessionName, windowIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killServer": {
|
||||
return runTmux(event, sessionId, "kill-server", 8000);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Unknown tmux action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listSessions,
|
||||
createSession,
|
||||
listWindows,
|
||||
listPanes,
|
||||
listClients,
|
||||
tmuxAction,
|
||||
shQuote,
|
||||
tmuxTarget,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTmuxOpsApi,
|
||||
shQuote,
|
||||
tmuxTarget,
|
||||
parseTmuxSessions,
|
||||
parseTmuxSessionsPlain,
|
||||
parseTmuxSessionNames,
|
||||
parseTmuxWindows,
|
||||
parseTmuxWindowsPlain,
|
||||
parseTmuxWindowsAll,
|
||||
parseTmuxWindowsAllPlain,
|
||||
filterWindowsForSession,
|
||||
splitTmuxFields,
|
||||
parseTmuxPaneRow,
|
||||
parseTmuxPanesPlain,
|
||||
parseTmuxPanes,
|
||||
parseTmuxClients,
|
||||
isNoTmuxServerMessage,
|
||||
};
|
||||
304
electron/bridges/systemManagerBridge.cjs
Normal file
304
electron/bridges/systemManagerBridge.cjs
Normal file
@@ -0,0 +1,304 @@
|
||||
"use strict";
|
||||
|
||||
const { createExecOnSessionApi } = require("./systemManager/execOnSession.cjs");
|
||||
const { createTmuxOpsApi } = require("./systemManager/tmuxOps.cjs");
|
||||
const { createDockerOpsApi } = require("./systemManager/dockerOps.cjs");
|
||||
|
||||
const CAPABILITY_SCRIPT_POSIX = [
|
||||
"exec sh -c ",
|
||||
"'",
|
||||
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
|
||||
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
|
||||
'docker info >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
const PROCESS_LIST_SCRIPT_POSIX = [
|
||||
"exec sh -c ",
|
||||
"'",
|
||||
"ps -eo pid=,ppid=,user=,stat=,pcpu=,pmem=,rss=,vsz=,etime=,args= 2>/dev/null | head -n 200",
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
function parseCapabilities(stdout, isLocal, localPlatform) {
|
||||
const text = stdout || "";
|
||||
let targetOs = "unknown";
|
||||
if (isLocal) {
|
||||
if (localPlatform === "linux") targetOs = "linux";
|
||||
else if (localPlatform === "darwin") targetOs = "darwin";
|
||||
else if (localPlatform === "win32") targetOs = "win32";
|
||||
} else {
|
||||
const osMatch = text.match(/__NC_OS__=([^\r\n]+)/);
|
||||
const uname = (osMatch?.[1] || "").trim().toLowerCase();
|
||||
if (uname.includes("linux")) targetOs = "linux";
|
||||
else if (uname.includes("darwin")) targetOs = "darwin";
|
||||
else if (uname.includes("windows") || uname.includes("mingw")) targetOs = "win32";
|
||||
}
|
||||
const hasTmux = text.includes("__NC_TMUX__=1");
|
||||
const hasDocker = text.includes("__NC_DOCKER__=1");
|
||||
return { targetOs, hasTmux, hasDocker, probedAt: Date.now() };
|
||||
}
|
||||
|
||||
function parseProcessLines(stdout) {
|
||||
const processes = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
processes.push({
|
||||
pid: Number(m[1]),
|
||||
ppid: Number(m[2]),
|
||||
user: m[3],
|
||||
stat: m[4],
|
||||
cpuPercent: Number(m[5]),
|
||||
memPercent: Number(m[6]),
|
||||
rssKb: Number(m[7]),
|
||||
vszKb: Number(m[8]),
|
||||
elapsed: m[9],
|
||||
command: m[10],
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
const ALLOWED_SIGNALS = new Set([
|
||||
"TERM", "KILL", "STOP", "CONT", "HUP", "INT", "USR1", "USR2",
|
||||
"1", "2", "9", "15", "18", "19",
|
||||
]);
|
||||
|
||||
function buildProcessSignalCommand(pid, signal, nice) {
|
||||
if (nice !== undefined && nice !== null) {
|
||||
const n = Number(nice);
|
||||
if (!Number.isFinite(n) || n < -20 || n > 19) {
|
||||
return { error: "Invalid nice value" };
|
||||
}
|
||||
return { command: `renice ${Math.trunc(n)} -p ${Number(pid)}` };
|
||||
}
|
||||
const sig = String(signal || "TERM").toUpperCase();
|
||||
if (!ALLOWED_SIGNALS.has(sig)) {
|
||||
return { error: "Invalid signal" };
|
||||
}
|
||||
const numericPid = Number(pid);
|
||||
if (!Number.isFinite(numericPid) || numericPid <= 0) {
|
||||
return { error: "Invalid pid" };
|
||||
}
|
||||
if (sig === "KILL" || sig === "9") {
|
||||
return { command: `kill -9 ${numericPid}` };
|
||||
}
|
||||
if (sig === "TERM" || sig === "15") {
|
||||
return { command: `kill -15 ${numericPid}` };
|
||||
}
|
||||
if (/^\d+$/.test(sig)) {
|
||||
return { command: `kill -${sig} ${numericPid}` };
|
||||
}
|
||||
return { command: `kill -s ${sig} ${numericPid}` };
|
||||
}
|
||||
|
||||
function createSystemManagerBridge(deps) {
|
||||
const {
|
||||
getSessions,
|
||||
execOnEtSession,
|
||||
ensureMoshStatsConnection,
|
||||
process,
|
||||
} = deps;
|
||||
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: (id) => getSessions()?.get(id) },
|
||||
execOnEtSession,
|
||||
ensureMoshStatsConnection,
|
||||
});
|
||||
|
||||
const { execOnSession, execOnLocalMachine, isLocalSession } = execApi;
|
||||
|
||||
const tmuxOps = createTmuxOpsApi({ execOnSession });
|
||||
const dockerOps = createDockerOpsApi({ execOnSession });
|
||||
|
||||
async function probeCapabilities(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
if (isLocalSession(sessionId)) {
|
||||
const platform = process.platform;
|
||||
let script = CAPABILITY_SCRIPT_POSIX;
|
||||
if (platform === "win32") {
|
||||
const result = await execOnLocalMachine(
|
||||
"$os=[System.Environment]::OSVersion.Platform; Write-Output \"__NC_OS__=Windows\"; if (Get-Command tmux -ErrorAction SilentlyContinue) { Write-Output '__NC_TMUX__=1' }; docker info 2>$null; if ($LASTEXITCODE -eq 0) { Write-Output '__NC_DOCKER__=1' }",
|
||||
8000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error || "Probe failed" };
|
||||
return { success: true, capabilities: parseCapabilities(result.stdout, true, platform) };
|
||||
}
|
||||
const result = await execOnLocalMachine(
|
||||
script.replace(/^exec sh -c '/, "").replace(/'$/, ""),
|
||||
8000,
|
||||
);
|
||||
if (!result.success) {
|
||||
const fallback = await execOnLocalMachine("uname -s; command -v tmux; docker info >/dev/null 2>&1 && echo docker_ok", 8000);
|
||||
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
|
||||
const text = fallback.stdout || "";
|
||||
return {
|
||||
success: true,
|
||||
capabilities: {
|
||||
targetOs: platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : "unknown",
|
||||
hasTmux: text.includes("tmux") && !text.includes("not found"),
|
||||
hasDocker: text.includes("docker_ok"),
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: true, capabilities: parseCapabilities(result.stdout, true, platform) };
|
||||
}
|
||||
|
||||
const result = await execOnSession(event, sessionId, CAPABILITY_SCRIPT_POSIX, 8000);
|
||||
if (result.pending) return { success: false, pending: true };
|
||||
if (!result.success) return { success: false, error: result.error || "Probe failed" };
|
||||
return {
|
||||
success: true,
|
||||
capabilities: parseCapabilities(result.stdout, false, process.platform),
|
||||
};
|
||||
}
|
||||
|
||||
async function listProcesses(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
if (isLocalSession(sessionId) && process.platform === "win32") {
|
||||
const result = await execOnLocalMachine(
|
||||
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 200 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
|
||||
10000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const raw = JSON.parse(result.stdout || "[]");
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
const processes = list.map((p) => ({
|
||||
pid: Number(p.ProcessId),
|
||||
ppid: Number(p.ParentProcessId) || 0,
|
||||
user: "",
|
||||
stat: "R",
|
||||
cpuPercent: 0,
|
||||
memPercent: 0,
|
||||
rssKb: Math.round((Number(p.WorkingSetSize) || 0) / 1024),
|
||||
vszKb: 0,
|
||||
elapsed: "",
|
||||
command: String(p.Name || ""),
|
||||
}));
|
||||
return { success: true, processes };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to parse process list" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await execOnSession(event, sessionId, PROCESS_LIST_SCRIPT_POSIX, 12000);
|
||||
if (result.pending) return { success: false, pending: true };
|
||||
if (!result.success) return { success: false, error: result.error || "Failed to list processes" };
|
||||
return { success: true, processes: parseProcessLines(result.stdout) };
|
||||
}
|
||||
|
||||
async function signalProcess(event, payload) {
|
||||
const { sessionId, pid, signal = "TERM", nice } = payload || {};
|
||||
if (!sessionId || !pid) return { success: false, error: "Missing sessionId or pid" };
|
||||
const built = buildProcessSignalCommand(pid, signal, nice);
|
||||
if (built.error) return { success: false, error: built.error };
|
||||
const result = await execOnSession(event, sessionId, `exec sh -c ${JSON.stringify(built.command)}`, 5000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, code: result.code };
|
||||
}
|
||||
|
||||
async function listTmuxSessions(event, payload) {
|
||||
const sessionId = typeof payload === "string" ? payload : payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
return tmuxOps.listSessions(event, sessionId);
|
||||
}
|
||||
|
||||
async function createTmuxSession(event, payload) {
|
||||
return tmuxOps.createSession(event, payload);
|
||||
}
|
||||
|
||||
async function listTmuxWindows(event, payload) {
|
||||
return tmuxOps.listWindows(event, payload);
|
||||
}
|
||||
|
||||
async function listTmuxPanes(event, payload) {
|
||||
return tmuxOps.listPanes(event, payload);
|
||||
}
|
||||
|
||||
async function listTmuxClients(event, payload) {
|
||||
return tmuxOps.listClients(event, payload);
|
||||
}
|
||||
|
||||
async function tmuxAction(event, payload) {
|
||||
const result = await tmuxOps.tmuxAction(event, payload);
|
||||
if (result.success === false && result.error) {
|
||||
return { success: false, error: result.error || result.stderr };
|
||||
}
|
||||
if (result.success === false) {
|
||||
return { success: false, error: result.stderr || "tmux command failed" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function listDockerContainers(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
return dockerOps.listContainers(event, sessionId);
|
||||
}
|
||||
|
||||
async function listDockerImages(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
return dockerOps.listImages(event, sessionId);
|
||||
}
|
||||
|
||||
async function dockerStats(event, payload) {
|
||||
return dockerOps.getStats(event, payload);
|
||||
}
|
||||
|
||||
async function dockerInspect(event, payload) {
|
||||
return dockerOps.inspectContainer(event, payload);
|
||||
}
|
||||
|
||||
async function dockerImageInspect(event, payload) {
|
||||
return dockerOps.inspectImage(event, payload);
|
||||
}
|
||||
|
||||
async function dockerAction(event, payload) {
|
||||
const result = await dockerOps.containerAction(event, payload);
|
||||
if (result.success === false) {
|
||||
return { success: false, error: result.error || result.stderr || "docker command failed" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function dockerImageAction(event, payload) {
|
||||
const result = await dockerOps.imageAction(event, payload);
|
||||
if (result.success === false) {
|
||||
return { success: false, error: result.error || result.stderr || "docker command failed" };
|
||||
}
|
||||
return { success: true, output: result.stdout };
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:system:probeCapabilities", probeCapabilities);
|
||||
ipcMain.handle("netcatty:system:listProcesses", listProcesses);
|
||||
ipcMain.handle("netcatty:system:signalProcess", signalProcess);
|
||||
ipcMain.handle("netcatty:system:listTmuxSessions", listTmuxSessions);
|
||||
ipcMain.handle("netcatty:system:createTmuxSession", createTmuxSession);
|
||||
ipcMain.handle("netcatty:system:listTmuxWindows", listTmuxWindows);
|
||||
ipcMain.handle("netcatty:system:listTmuxPanes", listTmuxPanes);
|
||||
ipcMain.handle("netcatty:system:listTmuxClients", listTmuxClients);
|
||||
ipcMain.handle("netcatty:system:tmuxAction", tmuxAction);
|
||||
ipcMain.handle("netcatty:system:listDockerContainers", listDockerContainers);
|
||||
ipcMain.handle("netcatty:system:listDockerImages", listDockerImages);
|
||||
ipcMain.handle("netcatty:system:dockerStats", dockerStats);
|
||||
ipcMain.handle("netcatty:system:dockerInspect", dockerInspect);
|
||||
ipcMain.handle("netcatty:system:dockerImageInspect", dockerImageInspect);
|
||||
ipcMain.handle("netcatty:system:dockerAction", dockerAction);
|
||||
ipcMain.handle("netcatty:system:dockerImageAction", dockerImageAction);
|
||||
}
|
||||
|
||||
return { registerHandlers, probeCapabilities, listProcesses };
|
||||
}
|
||||
|
||||
module.exports = { createSystemManagerBridge };
|
||||
@@ -661,9 +661,15 @@ main();
|
||||
windowsHide: true,
|
||||
}, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
resolve({ success: false, error: err.message, stdout, stderr });
|
||||
resolve({
|
||||
success: false,
|
||||
error: err.message,
|
||||
stdout: stdout || "",
|
||||
stderr: stderr || "",
|
||||
code: typeof err.code === "number" && err.code !== 0 ? err.code : 1,
|
||||
});
|
||||
} else {
|
||||
resolve({ success: true, stdout: stdout || "", stderr: stderr || "" });
|
||||
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -894,6 +894,22 @@ const {
|
||||
prewarmSettingsWindow,
|
||||
} = settingsWindowApi;
|
||||
|
||||
const { createTerminalPopupWindowApi } = require("./windowManager/terminalPopupWindow.cjs");
|
||||
const terminalPopupWindowApi = createTerminalPopupWindowApi({
|
||||
get mainWindow() { return mainWindow; },
|
||||
get currentTheme() { return currentTheme; },
|
||||
V8_CACHE_OPTIONS,
|
||||
__dirname,
|
||||
resolveFrontendBackgroundColor,
|
||||
createExternalOnlyWindowOpenHandler,
|
||||
getDevRendererBaseUrl,
|
||||
applyWindowOpacityToWindow,
|
||||
sendWhenRendererReady,
|
||||
showAndFocusWindow,
|
||||
resolveSettingsWindowBounds,
|
||||
});
|
||||
const { openTerminalPopupWindow, closeTerminalPopupWindow } = terminalPopupWindowApi;
|
||||
|
||||
/**
|
||||
* Register window control IPC handlers (only once)
|
||||
*/
|
||||
@@ -1174,6 +1190,8 @@ module.exports = {
|
||||
createWindow,
|
||||
openSettingsWindow,
|
||||
closeSettingsWindow,
|
||||
openTerminalPopupWindow,
|
||||
closeTerminalPopupWindow,
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
|
||||
125
electron/bridges/windowManager/terminalPopupWindow.cjs
Normal file
125
electron/bridges/windowManager/terminalPopupWindow.cjs
Normal file
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
function createTerminalPopupWindowApi(ctx) {
|
||||
with (ctx) {
|
||||
const terminalPopupWindows = new Map();
|
||||
|
||||
function isLiveWindow(win) {
|
||||
return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed());
|
||||
}
|
||||
|
||||
async function openTerminalPopupWindow(electronModule, options, payload) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow } = options;
|
||||
|
||||
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
const backgroundColor = frontendBackground || "#1a1a1a";
|
||||
|
||||
const popupWidth = 920;
|
||||
const popupHeight = 580;
|
||||
const { x: popupX, y: popupY } = resolveSettingsWindowBounds(electronModule, {
|
||||
sourceWindow: sourceWindow || mainWindow,
|
||||
settingsWidth: popupWidth,
|
||||
settingsHeight: popupHeight,
|
||||
});
|
||||
|
||||
const title = typeof payload?.title === "string" && payload.title.trim()
|
||||
? payload.title.trim()
|
||||
: "Terminal";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title,
|
||||
width: popupWidth,
|
||||
height: popupHeight,
|
||||
...(popupX !== undefined && popupY !== undefined ? { x: popupX, y: popupY } : {}),
|
||||
minWidth: 480,
|
||||
minHeight: 320,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? "hiddenInset" : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
const popupId = String(payload?.popupId || Date.now());
|
||||
terminalPopupWindows.set(popupId, win);
|
||||
|
||||
try {
|
||||
win.webContents?.setWindowOpenHandler?.(
|
||||
createExternalOnlyWindowOpenHandler(shell),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
win.on("closed", () => {
|
||||
terminalPopupWindows.delete(popupId);
|
||||
});
|
||||
|
||||
win.on("page-title-updated", (e) => { e.preventDefault(); });
|
||||
|
||||
try {
|
||||
win.setBackgroundColor(backgroundColor);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
applyWindowOpacityToWindow(win);
|
||||
|
||||
const popupPath = "/#/terminal-popup";
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${popupPath}`);
|
||||
} catch (e) {
|
||||
console.warn("[TerminalPopup] Dev server not reachable", e);
|
||||
await win.loadURL(`app://netcatty/index.html${popupPath}`);
|
||||
}
|
||||
} else {
|
||||
await win.loadURL(`app://netcatty/index.html${popupPath}`);
|
||||
}
|
||||
|
||||
const delivery = await sendWhenRendererReady(
|
||||
win,
|
||||
"netcatty:window:terminalPopupConfig",
|
||||
{ ...payload, popupId },
|
||||
{ timeoutMs: 10000 },
|
||||
);
|
||||
|
||||
if (!delivery.success) {
|
||||
try { win.destroy(); } catch { /* ignore */ }
|
||||
terminalPopupWindows.delete(popupId);
|
||||
return { success: false, error: delivery.error || "Popup failed to receive config" };
|
||||
}
|
||||
|
||||
showAndFocusWindow(win);
|
||||
return { success: true, popupId };
|
||||
}
|
||||
|
||||
function closeTerminalPopupWindow(popupId) {
|
||||
const win = terminalPopupWindows.get(popupId);
|
||||
if (isLiveWindow(win)) {
|
||||
try { win.close(); } catch { /* ignore */ }
|
||||
}
|
||||
terminalPopupWindows.delete(popupId);
|
||||
}
|
||||
|
||||
return {
|
||||
openTerminalPopupWindow,
|
||||
closeTerminalPopupWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createTerminalPopupWindowApi };
|
||||
@@ -159,6 +159,15 @@ function createBridgeRegistrar(context) {
|
||||
transferBridge.registerHandlers(ipcMain);
|
||||
portForwardingBridge.registerHandlers(ipcMain);
|
||||
terminalBridge.registerHandlers(ipcMain);
|
||||
|
||||
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
|
||||
const systemManagerBridge = createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
execOnEtSession: (...args) => terminalBridge.execOnEtSession(...args),
|
||||
ensureMoshStatsConnection: (...args) => sshBridge.ensureMoshStatsConnection(...args),
|
||||
process,
|
||||
});
|
||||
systemManagerBridge.registerHandlers(ipcMain);
|
||||
oauthBridge.setupOAuthBridge(ipcMain);
|
||||
githubAuthBridge.registerHandlers(ipcMain);
|
||||
googleAuthBridge.registerHandlers(ipcMain, electronModule);
|
||||
@@ -345,6 +354,27 @@ function createBridgeRegistrar(context) {
|
||||
return { success: false, error: err?.message || "Failed to open new window" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:openTerminalPopup", async (event, payload) => {
|
||||
try {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { success: false, error: "Invalid popup payload" };
|
||||
}
|
||||
const sourceWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
return await getWindowManager().openTerminalPopupWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
sourceWindow,
|
||||
}, payload);
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to open terminal popup:", err);
|
||||
return { success: false, error: err?.message || "Failed to open terminal popup" };
|
||||
}
|
||||
});
|
||||
|
||||
// Cloud sync master password (stored in-memory + persisted via safeStorage)
|
||||
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {
|
||||
|
||||
@@ -74,6 +74,62 @@ function createPreloadApi(ctx) {
|
||||
getServerStats: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:stats", { sessionId });
|
||||
},
|
||||
probeSystemCapabilities: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:system:probeCapabilities", { sessionId });
|
||||
},
|
||||
listSystemProcesses: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listProcesses", { sessionId });
|
||||
},
|
||||
signalSystemProcess: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:signalProcess", options);
|
||||
},
|
||||
listTmuxSessions: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listTmuxSessions", { sessionId });
|
||||
},
|
||||
createTmuxSession: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:createTmuxSession", options);
|
||||
},
|
||||
listTmuxWindows: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listTmuxWindows", options);
|
||||
},
|
||||
listTmuxPanes: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listTmuxPanes", options);
|
||||
},
|
||||
listTmuxClients: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listTmuxClients", options);
|
||||
},
|
||||
tmuxAction: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:tmuxAction", options);
|
||||
},
|
||||
listDockerContainers: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listDockerContainers", { sessionId });
|
||||
},
|
||||
listDockerImages: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:system:listDockerImages", { sessionId });
|
||||
},
|
||||
getDockerStats: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:dockerStats", options);
|
||||
},
|
||||
dockerInspect: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:dockerInspect", options);
|
||||
},
|
||||
dockerImageInspect: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:dockerImageInspect", options);
|
||||
},
|
||||
dockerAction: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:dockerAction", options);
|
||||
},
|
||||
dockerImageAction: async (options) => {
|
||||
return ipcRenderer.invoke("netcatty:system:dockerImageAction", options);
|
||||
},
|
||||
openTerminalPopup: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:window:openTerminalPopup", payload);
|
||||
},
|
||||
onTerminalPopupConfig: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:window:terminalPopupConfig", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:terminalPopupConfig", handler);
|
||||
},
|
||||
readRemoteHistory: async (sessionId, limit) => {
|
||||
return ipcRenderer.invoke("netcatty:ssh:readRemoteHistory", { sessionId, limit });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user