Site approvals and panel approvals share one allowedOrigins list on disk because the policy is one SitePolicy either way, but on screen they are two different things: a URL you signed in on versus an Aegis sidebar panel you unlocked. The panel showed them in one mono list under "Sites Claude may control", so an Aegis approval read as "theseus-panel://aegis" with no label and no clue it was a panel at all. publicState() now splits allowedOrigins into allowedSites (web origins) and allowedPanels (theseus-panel://<addon>, enriched with the add-on's own title via api.tabControl.list()). The panel.html renders them in their own sections: "Sites Claude may control" and "Add-on panels Claude may control", the latter hidden when nothing is approved. Each panel row shows the add-on title in bold with the mono origin as a subtitle. The ✕ button goes through the existing remove-origin IPC — removing either kind takes one call. The renderer falls back to deriving the split from allowedOrigins so a UI loaded against an older host still renders both sections.
295 lines
19 KiB
HTML
295 lines
19 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Claude connector</title>
|
||
<style>
|
||
:root { color-scheme: light dark;
|
||
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
|
||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --clay:#d97757; --bad:#ff9081;
|
||
--btn:#1c2432; --btn-h:#242e40; }
|
||
@media (prefers-color-scheme: light) {
|
||
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
|
||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --btn:#f0f3fa; --btn-h:#e3e8f2; --bad:#c43c2c; }
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; }
|
||
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
|
||
header .t { font-weight: 600; flex: 1; }
|
||
.pill { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--mut); white-space: nowrap; }
|
||
.pill.on { color: var(--clay); border-color: rgb(217 119 87 / .6); background: rgb(217 119 87 / .12); }
|
||
.pill.idle { color: var(--acid); border-color: rgb(from var(--acid) r g b / .35); }
|
||
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
||
h2 { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); margin: 0 0 8px; font-weight: 600; }
|
||
p { margin: 0 0 8px; color: var(--mut); }
|
||
.row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
|
||
.row .grow { flex: 1; min-width: 0; }
|
||
.sw { position: relative; width: 34px; height: 20px; flex: none; }
|
||
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||
.sw span { position: absolute; inset: 0; background: var(--btn); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; transition: .15s; }
|
||
.sw span::after { content: ""; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; background: var(--mut); border-radius: 50%; transition: .15s; }
|
||
.sw input:checked + span { background: rgb(217 119 87 / .35); border-color: var(--clay); }
|
||
.sw input:checked + span::after { transform: translateX(14px); background: var(--clay); }
|
||
button.b { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--btn); color: var(--ink); cursor: pointer; white-space: nowrap; }
|
||
button.b:hover { background: var(--btn-h); }
|
||
button.b.warn { color: var(--bad); }
|
||
button.x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 13px; padding: 2px 6px; }
|
||
button.x:hover { color: var(--bad); }
|
||
pre { margin: 6px 0; padding: 8px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; font: 11.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; }
|
||
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||
.list { margin: 4px 0; padding: 0; list-style: none; }
|
||
.list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px dashed var(--line); }
|
||
.list li:last-child { border-bottom: none; }
|
||
.list li .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||
.empty { color: var(--dim); font-style: italic; }
|
||
.note { font-size: 12px; color: var(--mut); }
|
||
.warnbox { font-size: 12px; color: var(--bad); margin-top: 4px; }
|
||
.log { font: 11.5px/1.4 ui-monospace, Consolas, monospace; }
|
||
.log .e { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||
.log .e .h { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.log .ts { color: var(--dim); }
|
||
.log .tool { color: var(--ink); font-weight: 600; }
|
||
.log .org { color: var(--mut); }
|
||
.log .ok { color: var(--acid); }
|
||
.log .bad { color: var(--bad); }
|
||
.log .a { color: var(--mut); overflow-wrap: anywhere; }
|
||
.app { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px dashed var(--line); }
|
||
.app:last-of-type { border-bottom: none; }
|
||
.app .grow { flex: 1; min-width: 0; }
|
||
.app .st { font-size: 12px; color: var(--mut); }
|
||
.app .st.ok { color: var(--acid); }
|
||
.app .st.warn { color: var(--clay); }
|
||
button.b.pri { background: rgb(217 119 87 / .2); border-color: var(--clay); color: var(--ink); }
|
||
button.b:disabled { opacity: .5; cursor: default; }
|
||
details.manual { margin-top: 8px; }
|
||
details.manual summary { cursor: pointer; color: var(--mut); font-size: 12px; }
|
||
.msg { font-size: 12px; margin-top: 6px; color: var(--mut); }
|
||
.msg.bad { color: var(--bad); }
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||
.tabs button { font: inherit; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); background: var(--btn); color: var(--mut); border-radius: 6px; cursor: pointer; }
|
||
.tabs button.sel { color: var(--ink); border-color: var(--clay); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><span class="t">Claude connector</span><span class="pill" id="pill">…</span></header>
|
||
|
||
<section>
|
||
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
|
||
<div class="row" style="margin-top:8px;align-items:flex-start"><div class="grow"><b>Simultaneous sessions</b><div class="note">A new client's <code>initialize</code> past this limit is refused with 429. Live sessions only — abandoned ones time out.</div></div>
|
||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||
<label><input type="radio" name="smode" value="1"> 1 only</label>
|
||
<label><input type="radio" name="smode" value="multi"> Multi</label>
|
||
<label><input type="radio" name="smode" value="0"> No limit</label>
|
||
<input type="number" id="sNum" min="1" max="100" step="1" style="width:60px" title="Multi: enter the cap (1-100)" hidden>
|
||
</div>
|
||
</div>
|
||
<div id="clients" class="note"></div>
|
||
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
|
||
</section>
|
||
|
||
<section id="setup">
|
||
<h2>Connect Claude</h2>
|
||
<p>One click adds Theseus to a Claude app on this computer. Theseus keeps the entry current when its token or address changes.</p>
|
||
<div class="app"><div class="grow"><b>Claude Code</b><div class="st" id="stCode">…</div></div><button class="b" id="rmCode" hidden>Remove</button><button class="b pri" id="goCode">Connect</button></div>
|
||
<div class="app"><div class="grow"><b>Claude Desktop</b><div class="st" id="stDesk">…</div></div><button class="b" id="rmDesk" hidden>Remove</button><button class="b pri" id="goDesk">Connect</button></div>
|
||
<div class="msg" id="appMsg"></div>
|
||
<details class="manual"><summary>Set up by hand</summary>
|
||
<div class="tabs"><button id="tCode" class="sel">Claude Code</button><button id="tDesk">Claude Desktop</button></div>
|
||
<div id="pCode">
|
||
<p>Run once in a terminal. <span class="mono">--scope user</span> makes it available in every project.</p>
|
||
<pre id="cmdCode"></pre>
|
||
<div class="row"><button class="b" id="copyCode">Copy command</button></div>
|
||
</div>
|
||
<div id="pDesk" hidden>
|
||
<p>Add to <span class="mono">claude_desktop_config.json</span> (Settings › Developer › Edit config), then restart Claude Desktop. It runs through Theseus itself, nothing else to install.</p>
|
||
<pre id="cmdDesk"></pre>
|
||
<div class="row"><button class="b" id="copyDesk">Copy config</button></div>
|
||
</div>
|
||
<p class="note">The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.</p>
|
||
</details>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Sites Claude may control</h2>
|
||
<p>Theseus asks the first time Claude acts on a site.</p>
|
||
<ul class="list" id="origins"></ul>
|
||
</section>
|
||
|
||
<section id="panelsSection" hidden>
|
||
<h2>Add-on panels Claude may control</h2>
|
||
<p>Each add-on is approved once. The approval covers every panel owned by that add-on, and the panel must be open in the sidebar for Claude to read or act on it.</p>
|
||
<ul class="list" id="panels"></ul>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Upload folders</h2>
|
||
<p>Claude can attach files only from these folders.</p>
|
||
<ul class="list" id="dirs"></ul>
|
||
<div class="row"><button class="b" id="addDir">Add folder…</button></div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Advanced</h2>
|
||
<div class="row"><div class="grow"><b>Allow evaluate</b><div class="note">Lets Claude run its own JavaScript in approved sites' pages.</div></div><label class="sw"><input type="checkbox" id="evaluate"><span></span></label></div>
|
||
<div class="warnbox" id="evalWarn" hidden>Evaluate runs any script as the page itself, including reading what you see there. Turn it off when you do not need it.</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Activity</h2>
|
||
<div class="row"><span class="note grow">Every action, newest first.</span><button class="b" id="openLog">Log file</button><button class="b" id="clearLog">Clear</button></div>
|
||
<div class="log" id="log"></div>
|
||
</section>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const S = window.silentmode;
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
let state = null;
|
||
|
||
function render(st) {
|
||
state = st;
|
||
$("accept").checked = !!st.accept;
|
||
const pill = $("pill");
|
||
if (st.connected) { pill.textContent = "Connected"; pill.className = "pill on"; }
|
||
else if (st.listening) { pill.textContent = "Waiting for Claude"; pill.className = "pill idle"; }
|
||
else { pill.textContent = "Off"; pill.className = "pill"; }
|
||
$("where").textContent = st.listening ? `Listening on ${st.url} (this computer only)` : st.accept ? "Starting…" : "Not listening";
|
||
const sess = st.sessions || [];
|
||
$("clients").innerHTML = sess.length
|
||
? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("<br>") + (st.controlledTabs.length ? `<br>Controlling tab${st.controlledTabs.length === 1 ? "" : "s"} ${st.controlledTabs.join(", ")}` : "")
|
||
: (st.listening ? "No client connected." : "");
|
||
$("setup").hidden = !st.commands;
|
||
renderApps(st.apps);
|
||
if (st.commands) { $("cmdCode").textContent = st.commands.claudeCode; $("cmdDesk").textContent = st.commands.claudeDesktop; }
|
||
// Site approvals (http(s)) and panel approvals (theseus-panel://) are
|
||
// kept apart on screen even though they share one list on disk, so the
|
||
// user sees them for what they are. publicState() does the split and
|
||
// enriches each panel with the add-on's own title; the fallback keeps
|
||
// older hosts rendering the mixed list the way they used to.
|
||
const sites = Array.isArray(st.allowedSites) ? st.allowedSites : (st.allowedOrigins || []).filter((o) => !String(o).startsWith("theseus-panel://"));
|
||
const panels = Array.isArray(st.allowedPanels) ? st.allowedPanels : (st.allowedOrigins || []).filter((o) => String(o).startsWith("theseus-panel://")).map((o) => ({ origin: o, addon: o.slice("theseus-panel://".length), title: o.slice("theseus-panel://".length).replace(/^\w/, (c) => c.toUpperCase()) }));
|
||
$("origins").innerHTML = sites.length
|
||
? sites.map((o) => `<li><span class="grow mono">${esc(o)}</span><button class="x" data-origin="${esc(o)}" title="Remove">✕</button></li>`).join("")
|
||
: '<li class="empty">None yet.</li>';
|
||
$("panelsSection").hidden = panels.length === 0;
|
||
$("panels").innerHTML = panels.length
|
||
? panels.map((p) => `<li><span class="grow"><b>${esc(p.title)} panel</b><div class="note mono">${esc(p.origin)}</div></span><button class="x" data-origin="${esc(p.origin)}" title="Remove">✕</button></li>`).join("")
|
||
: "";
|
||
$("dirs").innerHTML = st.uploadDirs.length
|
||
? st.uploadDirs.map((d) => `<li><span class="grow mono">${esc(d)}</span><button class="x" data-dir="${esc(d)}" title="Remove">✕</button></li>`).join("")
|
||
: '<li class="empty">None. Uploads are refused.</li>';
|
||
$("evaluate").checked = !!st.allowEvaluate;
|
||
$("evalWarn").hidden = !st.allowEvaluate;
|
||
// Session-cap radios: 1-only / Multi / No-limit. Multi reveals a number
|
||
// input for the exact cap. 0 (unlimited) shows "No limit" selected.
|
||
const lim = Number(st.sessionLimit) || 0;
|
||
const smode = lim === 1 ? "1" : lim === 0 ? "0" : "multi";
|
||
for (const r of document.querySelectorAll('input[name="smode"]')) r.checked = (r.value === smode);
|
||
const num = $("sNum");
|
||
num.hidden = (smode !== "multi");
|
||
num.value = smode === "multi" ? String(lim) : (smode === "1" ? "" : "");
|
||
}
|
||
|
||
function entryHtml(e) {
|
||
const t = new Date(e.ts);
|
||
const ts = `${t.toLocaleDateString()} ${t.toLocaleTimeString()}`;
|
||
return `<div class="e"><div class="h"><span class="ts">${esc(ts)}</span><span class="tool">${esc(e.tool)}</span>${e.tab != null ? `<span class="org">tab ${esc(e.tab)}</span>` : ""}${e.origin ? `<span class="org">${esc(e.origin)}</span>` : ""}<span class="${e.ok ? "ok" : "bad"}">${e.ok ? "ok" : "failed"}</span>${e.ms != null ? `<span class="ts">${esc(e.ms)} ms</span>` : ""}</div>${e.args ? `<div class="a">${esc(e.args)}</div>` : ""}${e.error ? `<div class="bad">${esc(e.error)}</div>` : ""}</div>`;
|
||
}
|
||
async function loadLog() {
|
||
const list = await S.invoke("log", { n: 200 });
|
||
$("log").innerHTML = list.slice().reverse().map(entryHtml).join("") || '<div class="empty">Nothing yet.</div>';
|
||
}
|
||
|
||
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
|
||
// Session cap: the three radios + the number input share one setter.
|
||
for (const r of document.querySelectorAll('input[name="smode"]')) {
|
||
r.addEventListener("change", async () => {
|
||
const v = r.value;
|
||
$("sNum").hidden = (v !== "multi");
|
||
if (v === "0") render(await S.invoke("set-session-limit", { limit: 0 }));
|
||
else if (v === "1") render(await S.invoke("set-session-limit", { limit: 1 }));
|
||
else { // multi
|
||
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
|
||
$("sNum").value = String(n);
|
||
render(await S.invoke("set-session-limit", { limit: n }));
|
||
}
|
||
});
|
||
}
|
||
$("sNum").addEventListener("change", async () => {
|
||
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
|
||
$("sNum").value = String(n);
|
||
render(await S.invoke("set-session-limit", { limit: n }));
|
||
});
|
||
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
|
||
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
|
||
// Two clicks: every client has to be set up again afterwards.
|
||
let revokeArmed = null;
|
||
$("revoke").onclick = async () => {
|
||
const b = $("revoke");
|
||
if (!revokeArmed) { b.textContent = "Click again to revoke"; revokeArmed = setTimeout(() => { revokeArmed = null; b.textContent = "Revoke token"; }, 4000); return; }
|
||
clearTimeout(revokeArmed); revokeArmed = null; b.textContent = "Revoke token";
|
||
render(await S.invoke("revoke-token"));
|
||
};
|
||
$("addDir").onclick = async () => render(await S.invoke("add-upload-dir"));
|
||
$("copyCode").onclick = () => state && S.invoke("copy", { text: state.commands.claudeCode }).then(() => flash("copyCode"));
|
||
$("copyDesk").onclick = () => state && S.invoke("copy", { text: state.commands.claudeDesktop }).then(() => flash("copyDesk"));
|
||
$("clearLog").onclick = async () => { await S.invoke("clear-log"); loadLog(); };
|
||
$("openLog").onclick = () => S.invoke("open-log-folder");
|
||
$("tCode").onclick = () => { $("pCode").hidden = false; $("pDesk").hidden = true; $("tCode").classList.add("sel"); $("tDesk").classList.remove("sel"); };
|
||
$("tDesk").onclick = () => { $("pCode").hidden = true; $("pDesk").hidden = false; $("tDesk").classList.add("sel"); $("tCode").classList.remove("sel"); };
|
||
document.body.addEventListener("click", async (e) => {
|
||
const o = e.target.closest("[data-origin]");
|
||
if (o) render(await S.invoke("remove-origin", { origin: o.dataset.origin }));
|
||
const d = e.target.closest("[data-dir]");
|
||
if (d) render(await S.invoke("remove-upload-dir", { dir: d.dataset.dir }));
|
||
});
|
||
function flash(id) { const b = $(id), t = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = t; }, 1200); }
|
||
|
||
S.on("status", render);
|
||
S.on("log-entry", (e) => {
|
||
const box = $("log");
|
||
if (box.querySelector(".empty")) box.innerHTML = "";
|
||
box.insertAdjacentHTML("afterbegin", entryHtml(e));
|
||
while (box.children.length > 300) box.lastChild.remove();
|
||
});
|
||
// ---- Claude apps ----
|
||
const APP_UI = { claudeCode: ["stCode", "goCode", "rmCode"], claudeDesktop: ["stDesk", "goDesk", "rmDesk"] };
|
||
function renderApps(apps) {
|
||
if (!apps) return;
|
||
for (const [id, [stId, goId, rmId]] of Object.entries(APP_UI)) {
|
||
const a = apps[id] || {};
|
||
const st = $(stId), go = $(goId), rm = $(rmId);
|
||
if (!a.installed && !a.configured) { st.textContent = id === "claudeCode" ? "Not found on this computer (the claude command)." : "Not installed on this computer."; st.className = "st"; go.hidden = true; rm.hidden = true; continue; }
|
||
go.hidden = false;
|
||
if (a.current) { st.textContent = "Connected. Theseus keeps it up to date."; st.className = "st ok"; go.hidden = true; }
|
||
else if (a.configured) { st.textContent = "Set up with an old address or token."; st.className = "st warn"; go.textContent = "Update"; }
|
||
else { st.textContent = "Not connected."; st.className = "st"; go.textContent = "Connect"; }
|
||
go.disabled = !(state && state.listening) || !a.installed;
|
||
if (!(state && state.listening)) st.textContent += " Turn on Accept connections first.";
|
||
rm.hidden = !a.configured;
|
||
}
|
||
}
|
||
async function appAction(msg, app, btn) {
|
||
const m = $("appMsg");
|
||
btn.disabled = true; m.className = "msg"; m.textContent = "Working…";
|
||
try {
|
||
const st = await S.invoke(msg, { app });
|
||
render(st);
|
||
const name = app === "claudeCode" ? "Claude Code" : "Claude Desktop";
|
||
m.textContent = msg === "app-connect"
|
||
? (st.restart ? `Added. Restart ${name} to load it.` : `Added to ${name}. New Claude Code sessions see the Theseus tools; in a running one, use /mcp.`)
|
||
: (st.restart ? `Removed. Restart ${name} to unload it.` : `Removed from ${name}.`);
|
||
} catch (e) { m.className = "msg bad"; m.textContent = String(e && e.message || e).replace(/^Error invoking remote method '[^']*': (Error: )?/, ""); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
$("goCode").onclick = (e) => appAction("app-connect", "claudeCode", e.currentTarget);
|
||
$("goDesk").onclick = (e) => appAction("app-connect", "claudeDesktop", e.currentTarget);
|
||
$("rmCode").onclick = (e) => appAction("app-remove", "claudeCode", e.currentTarget);
|
||
$("rmDesk").onclick = (e) => appAction("app-remove", "claudeDesktop", e.currentTarget);
|
||
|
||
S.invoke("apps").then(render);
|
||
loadLog();
|
||
</script>
|
||
</body>
|
||
</html>
|