Files
nexus/frontend/src/js/raw.js
T
Andy DixonandClaude Sonnet 5 1a380a0e99 Rebrand: Nexus Terminal → Alchementary Link
Renames the product across the codebase: Go module path, config/data
paths, keychain service name, export file extension, build tooling,
docs, and UI text. Sidebar brand mark now reads ">_ AlchementaryLink"
with "Alchementary" in the connection-group gold accent colour, and
the window title is "Alchementary Link".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T8bJS8BCtY5GeSG2HBj1n
2026-09-03 16:41:23 +01:00

626 lines
18 KiB
JavaScript

(function () {
/**
* Alchementary Link — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
const { createElement, escapeHtml, formatTimestamp, formatBytes } =
window.AlchementaryLink;
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
class RawViewer {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Creates a RAW viewer inside the given panel container.
* @param {string} sessionId
* @param {string} containerId - DOM id of the panel element.
* @param {Object} profile
*/
createViewer(sessionId, containerId, profile) {
const container = document.getElementById(containerId);
if (!container) return;
const state = {
mode: "text", // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: "text", // text | hex | escaped
};
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement("div", "raw-toolbar");
// Mode toggle
const modeGroup = createElement("div", "raw-mode-group");
for (const mode of ["text", "hex", "split"]) {
const btn = createElement(
"button",
`raw-mode-btn ${mode === state.mode ? "active" : ""}`,
{
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
},
);
btn.addEventListener("click", () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Controls
const controls = createElement("div", "raw-controls");
const pauseBtn = createElement(
"button",
"btn btn-sm btn-outline raw-pause",
{ textContent: "⏸ Pause" },
);
pauseBtn.addEventListener("click", () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const clearBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "✕ Clear",
});
clearBtn.addEventListener("click", () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const saveBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "💾 Save",
});
saveBtn.addEventListener("click", () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
// Byte counter
const counter = createElement("span", "raw-counter", {
textContent: "0 B",
});
controls.appendChild(counter);
toolbar.appendChild(controls);
container.appendChild(toolbar);
// ── Data display ─────────────────────────────────────────
const display = createElement("div", "raw-display");
container.appendChild(display);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
}
/**
* Processes incoming data from the backend.
* @param {Object} payload - { sessionID, data, direction, timestamp }
*/
handleData(payload) {
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || "";
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const entry = {
direction: payload.direction || "recv", // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
}
/**
* Toggles pause/resume on the data display.
* @param {string} sessionId
*/
togglePause(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? "▶ Resume" : "⏸ Pause";
viewer.pauseBtn.classList.toggle("active", viewer.paused);
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
}
/**
* Clears the data buffer and display.
* @param {string} sessionId
*/
clearBuffer(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = "";
viewer.counter.textContent = "0 B";
}
/**
* Prompts the user to save the capture buffer.
* @param {string} sessionId
*/
async saveCapture(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning("Nothing to save");
return;
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === "send" ? "TX" : "RX";
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join("\n");
await window.AlchementaryLink.wailsService.SaveSessionCapture(sessionId, content);
this.app.notifications.success("Capture saved");
} catch (err) {
this.app.notifications.error("Save failed: " + err);
}
}
/**
* Sends data from the send bar to the backend.
* @param {string} sessionId
*/
async send(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const input = viewer.container.querySelector(".raw-send-input");
const formatSelect = viewer.container.querySelector(".raw-send-format");
if (!input) return;
const rawValue = input.value;
if (!rawValue) return;
const format = formatSelect?.value || "text";
let dataToSend;
switch (format) {
case "hex":
dataToSend = this._hexToString(rawValue);
break;
case "escaped":
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
try {
// For hex/escaped, data is already raw bytes (0-255) so btoa is correct.
// For text mode, UTF-8 encode so non-ASCII characters are transmitted properly.
const b64 =
format === "hex" || format === "escaped"
? btoa(dataToSend)
: window.AlchementaryLink.utf8ToBase64(dataToSend);
await window.AlchementaryLink.wailsService.WriteSession(sessionId, b64);
input.value = "";
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: "send",
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error("Send failed: " + err);
}
}
/**
* Tears down a viewer and frees resources.
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Renders the send bar at the bottom of the viewer.
* @param {string} sessionId
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSendBar(sessionId, profile) {
const bar = createElement("div", "raw-send-bar");
// Format selector
const formatSelect = createElement(
"select",
"form-select form-select-sm raw-send-format",
);
for (const [value, label] of [
["text", "Text"],
["hex", "Hex"],
["escaped", "Escaped"],
]) {
const opt = createElement("option", "", { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Input
const input = createElement("input", "form-input raw-send-input", {
type: "text",
placeholder: "Type data to send…",
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.send(sessionId);
});
bar.appendChild(input);
// Quick-insert buttons for common control bytes
const quickBtns = createElement("div", "raw-quick-btns");
const controlBytes = [
{ label: "NUL", value: "\\x00" },
{ label: "CR", value: "\\r" },
{ label: "LF", value: "\\n" },
{ label: "ETX", value: "\\x03" },
{ label: "EOT", value: "\\x04" },
];
for (const cb of controlBytes) {
const btn = createElement("button", "btn btn-xs btn-outline", {
textContent: cb.label,
title: cb.value,
});
btn.addEventListener("click", () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
}
bar.appendChild(quickBtns);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement(
"select",
"form-select form-select-sm raw-preset-select",
);
const defaultOpt = createElement("option", "", {
value: "",
textContent: "Presets…",
});
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement("option", "", {
value: ps.payload,
textContent: ps.name,
});
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
}
presetSelect.addEventListener("change", () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.value = "";
input.focus();
}
});
bar.appendChild(presetSelect);
}
// Send button
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send",
});
sendBtn.addEventListener("click", () => this.send(sessionId));
bar.appendChild(sendBtn);
return bar;
}
/**
* Renders a single data entry into the display area.
* @param {Object} viewer
* @param {Object} entry
* @private
*/
_renderEntry(viewer, entry) {
let el;
switch (viewer.mode) {
case "hex":
el = this._renderHexView(entry);
break;
case "split":
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
}
/**
* Re-renders all buffered entries (e.g. after mode change or resume).
* @param {Object} viewer
* @private
*/
_renderAllEntries(viewer) {
viewer.display.innerHTML = "";
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
}
/**
* Renders a text-mode entry.
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderTextView(entry, profile) {
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
let prefix = "";
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === "send" ? "[TX] " : "[RX] ";
}
const pre = createElement("pre", "raw-text");
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
}
/**
* Renders a hex-mode entry.
* @param {Object} entry
* @returns {HTMLElement}
* @private
*/
_renderHexView(entry) {
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const pre = createElement("pre", "raw-hex");
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
}
/**
* Renders a split-mode entry (text + hex side by side).
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSplitView(entry, profile) {
const row = createElement(
"div",
`raw-entry raw-entry-split raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
// Direction/timestamp header
const header = createElement("div", "raw-split-header");
let headerText = "";
if (raw.showTimestamps)
headerText += formatTimestamp(entry.timestamp) + " ";
headerText += entry.direction === "send" ? "▲ TX" : "▼ RX";
header.textContent = headerText;
row.appendChild(header);
// Split panes
const panes = createElement("div", "raw-split-panes");
const textPane = createElement("pre", "raw-text raw-split-text");
textPane.textContent = entry.data;
panes.appendChild(textPane);
const hexPane = createElement("pre", "raw-hex raw-split-hex");
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
row.appendChild(panes);
return row;
}
/**
* Formats a Uint8Array into a traditional hex dump:
* offset | hex bytes | ASCII representation.
* @param {Uint8Array} bytes
* @returns {string}
*/
formatHexDump(bytes) {
const lines = [];
const bytesPerLine = 16;
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
// Offset column
const offsetStr = offset.toString(16).padStart(8, "0");
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, "0"));
} else {
hexParts.push(" ");
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push("");
}
const hexStr = hexParts.join(" ");
// ASCII column
let ascii = "";
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".";
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join("\n");
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Changes the display mode (text, hex, split) and re-renders.
* @param {string} sessionId
* @param {string} mode
* @private
*/
_setMode(sessionId, mode) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.mode = mode;
// Update active button
viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this._renderAllEntries(viewer);
}
/**
* Scrolls the data display to the bottom.
* @param {Object} viewer
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
* Converts a hex string (e.g. "48656c6c6f") into a character string.
* @param {string} hex
* @returns {string}
* @private
*/
_hexToString(hex) {
const cleaned = hex.replace(/\s+/g, "");
let str = "";
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
}
/**
* Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes.
* @param {string} str
* @returns {string}
* @private
*/
_unescapeString(str) {
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case "n":
return "\n";
case "r":
return "\r";
case "t":
return "\t";
case "0":
return "\0";
case "\\":
return "\\";
default:
// \\xHH
if (seq.startsWith("x")) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
return match;
}
});
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.RawViewer = RawViewer;
})();