relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
208 lines (198 loc) • 8.35 kB
JavaScript
const { contextBridge, ipcRenderer, webUtils } = require("electron");
const MarkdownItModule = require("markdown-it");
const createDOMPurifyModule = require("dompurify");
const MarkdownIt = MarkdownItModule.default || MarkdownItModule;
const createDOMPurify = createDOMPurifyModule.default || createDOMPurifyModule;
const DOMPurify = createDOMPurify.sanitize ? createDOMPurify : createDOMPurify(window);
const MAX_PREVIEW_MARKDOWN_CHARS = 500_000;
const MAX_REPLY_FILES = 20;
function normalizedReplyFiles(files) {
return (Array.isArray(files) ? files : []).slice(0, MAX_REPLY_FILES).flatMap((file) => {
const localPath = String((file && file.path) || "");
const inline = Boolean(file && Object.prototype.hasOwnProperty.call(file, "contentBase64"));
if (!localPath && !inline) return [];
return [{
name: String((file && file.name) || "file"),
size: Math.max(0, Number((file && file.size) || 0) || 0),
contentType: String((file && file.contentType) || "application/octet-stream"),
path: localPath,
contentBase64: inline ? String(file.contentBase64 || "") : "",
}];
});
}
// Relay bodies are authored outside this renderer. Treat every URL as untrusted,
// even after Markdown parsing, and expose only schemes that Electron may safely
// hand to the operating system. Main must repeat this check before openExternal.
function safeExternalUrl(value) {
const raw = String(value || "").trim();
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) return null;
try {
const parsed = new URL(raw);
if (!new Set(["https:", "http:", "mailto:"]).has(parsed.protocol)) return null;
return parsed.href;
} catch {
return null;
}
}
const markdown = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: false,
});
markdown.validateLink = (url) => Boolean(safeExternalUrl(url));
// Remote images are intentionally never emitted. Besides keeping the panel
// text-first, this prevents a relay from making a tracking request merely by
// being previewed. Preserve useful alt text as inert prose.
markdown.renderer.rules.image = (tokens, index) => {
const alt = markdown.utils.escapeHtml(tokens[index].content || "").trim();
return alt ? `<em>[Image omitted: ${alt}]</em>` : "<em>[Image omitted]</em>";
};
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (!node || node.nodeType !== 1) return;
if (String(node.nodeName || "").toLowerCase() !== "a") return;
const safe = safeExternalUrl(node.getAttribute("href"));
if (!safe) {
node.removeAttribute("href");
node.removeAttribute("target");
node.removeAttribute("rel");
return;
}
node.setAttribute("href", safe);
node.setAttribute("rel", "noreferrer noopener");
});
function renderMarkdown(value) {
const source = String(value || "");
if (source.length > MAX_PREVIEW_MARKDOWN_CHARS) throw new Error("Relay detail is too large to preview safely.");
if (DOMPurify.isSupported === false) throw new Error("Safe Markdown rendering is unavailable.");
const rendered = markdown.render(source);
return DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: [
"a",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"s",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
],
ALLOWED_ATTR: ["href", "rel", "title"],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
FORBID_TAGS: ["audio", "canvas", "embed", "form", "iframe", "img", "input", "math", "object", "script", "style", "svg", "video"],
FORBID_ATTR: ["style", "src", "srcset", "target"],
RETURN_TRUSTED_TYPE: false,
SANITIZE_NAMED_PROPS: true,
});
}
contextBridge.exposeInMainWorld("relayPreview", {
onTheme: (callback) => {
const listener = (_event, value) => callback(value === "dark" ? "dark" : "light");
ipcRenderer.on("relay:preview:theme", listener);
return () => ipcRenderer.removeListener("relay:preview:theme", listener);
},
onContent: (callback) => {
if (typeof callback !== "function") return () => {};
const listener = (_event, content) => callback(content && typeof content === "object" ? content : {});
ipcRenderer.on("relay:preview:content", listener);
return () => ipcRenderer.removeListener("relay:preview:content", listener);
},
/**
* Fires when the inbox changed. Carries no data — it is a nudge to re-read
* the conversation, so nothing about other people's mail crosses the bridge.
*/
onMail: (callback) => {
if (typeof callback !== "function") return () => {};
const listener = () => callback();
ipcRenderer.on("relay:preview:mail", listener);
return () => ipcRenderer.removeListener("relay:preview:mail", listener);
},
ready: () => ipcRenderer.send("relay:preview:ready"),
rendered: (id) => ipcRenderer.send("relay:preview:rendered", String(id || "")),
renderedChat: (ids) => ipcRenderer.send(
"relay:preview:chat-rendered",
Array.from(new Set((Array.isArray(ids) ? ids : []).map((id) => String(id || "")).filter(Boolean))).slice(0, 500),
),
chatReadActivity: () => ipcRenderer.send("relay:chatReadActivity"),
minimize: () => ipcRenderer.send("relay:preview:minimize"),
close: () => ipcRenderer.send("relay:preview:close"),
openExternal: (url) => {
const safe = safeExternalUrl(url);
if (!safe) return false;
ipcRenderer.send("relay:preview:openExternal", safe);
return true;
},
// The conversation this message sits in, and the composer that adds to it.
// Every argument is coerced to a primitive here so the renderer can never
// hand the main process a structure to walk.
loadChat: (threadId) => ipcRenderer.invoke("relay:preview:chat", String(threadId || "")),
pathForFile: (file) => {
try { return webUtils.getPathForFile(file) || ""; } catch { return ""; }
},
sendReply: (inReplyToRelayId, body, idempotencyKey, files = []) =>
ipcRenderer.invoke("relay:preview:reply", {
inReplyToRelayId: String(inReplyToRelayId || ""),
body: String(body || ""),
// Minted once per composed message so a retry converges on the same relay
// rather than sending it twice.
idempotencyKey: String(idempotencyKey || ""),
files: normalizedReplyFiles(files),
}),
// The task face's ignition. Same coercion discipline as sendReply.
startTask: (relayId, note, host, model, effort) =>
ipcRenderer.invoke("relay:preview:startTask", {
relayId: String(relayId || ""),
note: String(note || ""),
host: String(host || "claude"),
model: String(model || ""),
effort: String(effort || ""),
}),
reviewSafety: (relayId) => ipcRenderer.invoke("relay:preview:reviewSafety", String(relayId || "")),
// The session face: the live transcript feed and the Steer verb.
loadSession: (relayId) => ipcRenderer.invoke("relay:preview:session", String(relayId || "")),
watchRunFeed: (relayId) => ipcRenderer.invoke("relay:runFeed:watch", String(relayId || "")),
unwatchRunFeed: (relayId) => ipcRenderer.send("relay:runFeed:unwatch", String(relayId || "")),
onRunFeedUpdate: (cb) => {
const listener = (_event, envelope) => cb(envelope);
ipcRenderer.on("relay:runFeed:update", listener);
return () => ipcRenderer.removeListener("relay:runFeed:update", listener);
},
runFeedDetail: (input) => ipcRenderer.invoke("relay:runFeed:detail", {
relayId:String(input?.relayId || ""), sessionId:String(input?.sessionId || ""),
turnId:String(input?.turnId || ""), itemId:String(input?.itemId || ""),
}),
runFeedAttachment: (input) => ipcRenderer.invoke("relay:runFeed:attachment", {
relayId: String(input?.relayId || ""),
sessionId: String(input?.sessionId || ""),
turnId: String(input?.turnId || ""),
itemId: String(input?.itemId || ""),
attachmentId: String(input?.attachmentId || ""),
attachmentIndex: Number.isInteger(input?.attachmentIndex) ? input.attachmentIndex : -1
}),
steer: (relayId, body, newTurn = false) =>
ipcRenderer.invoke("relay:preview:steer", {
relayId: String(relayId || ""),
body: String(body || ""),
newTurn: newTurn === true,
}),
renderMarkdown,
});