Theseus 0.3.106: AI engine picker as a styled overlay with real icons
Right-click or chevron-click on the sidebar strip's AI button now opens a borderless overlay instead of a plain native menu: enabled engines are listed with their bundled icons, the current engine is highlighted with the acid accent + check, and a settings row at the bottom jumps to Search settings. Clicking an engine selects it as the default and opens it in the left panel in one click. Bundled engine icons gained an svg-first fallback: engine-icons/<id>.svg is picked up before <id>.png so plain color-coded letter tiles ship alongside the existing bitmap favicons. Added tiles for ChatGPT, Grok, Gemini and Phind — each a simple initial-on-color mark.
This commit is contained in:
parent
964536b5c8
commit
fde510eece
8 changed files with 382 additions and 38 deletions
1
engine-icons/chatgpt.svg
Normal file
1
engine-icons/chatgpt.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ChatGPT"><rect width="64" height="64" rx="14" fill="#10a37f"/><text x="32" y="42" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="800" font-size="34" fill="#ffffff">C</text></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
engine-icons/gemini.svg
Normal file
1
engine-icons/gemini.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Gemini"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4b7bec"/><stop offset="1" stop-color="#8e65d9"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#g)"/><text x="32" y="42" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="800" font-size="34" fill="#ffffff">G</text></svg>
|
||||
|
After Width: | Height: | Size: 456 B |
1
engine-icons/grok.svg
Normal file
1
engine-icons/grok.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Grok"><rect width="64" height="64" rx="14" fill="#121418"/><text x="32" y="42" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="800" font-size="34" fill="#e7eaf1">G</text></svg>
|
||||
|
After Width: | Height: | Size: 295 B |
1
engine-icons/phind.svg
Normal file
1
engine-icons/phind.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Phind"><rect width="64" height="64" rx="14" fill="#0b0e14"/><text x="32" y="42" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="800" font-size="34" fill="#36c7a0">P</text></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
10
llm-picker-preload.js
Normal file
10
llm-picker-preload.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("llmPicker", {
|
||||
ready: () => ipcRenderer.invoke("llm-picker-ready"),
|
||||
pick: (id) => ipcRenderer.invoke("llm-picker-pick", id),
|
||||
settings: () => ipcRenderer.invoke("llm-picker-settings"),
|
||||
close: () => ipcRenderer.invoke("llm-picker-close"),
|
||||
resize: (w, h) => ipcRenderer.invoke("llm-picker-resize", { w, h }),
|
||||
onState: (fn) => ipcRenderer.on("llm-picker-state", (_e, s) => fn(s)),
|
||||
});
|
||||
106
llm-picker.html
Normal file
106
llm-picker.html
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pick AI</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #141a24; --card: #1a2130; --card-hi: #212a3b; --line: #2a3344;
|
||||
--ink: #e7eaf1; --mut: #8b98a9; --acid: #d6ff3d; --acid-tint: #d6ff3d1a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: transparent; }
|
||||
body {
|
||||
display: inline-block;
|
||||
font: 13px/1.3 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.pop {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.4);
|
||||
overflow: hidden; width: 240px;
|
||||
}
|
||||
.hdr { padding: 10px 14px 6px; font-size: 11px; font-weight: 600;
|
||||
color: var(--mut); text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
.list { display: flex; flex-direction: column; padding: 4px; }
|
||||
.row { display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer; position: relative;
|
||||
transition: background .08s; }
|
||||
.row:hover { background: var(--card-hi); }
|
||||
.row.cur { background: var(--acid-tint); }
|
||||
.row.cur::before { content: ""; position: absolute; left: 2px; top: 20%; bottom: 20%;
|
||||
width: 3px; border-radius: 2px; background: var(--acid); }
|
||||
.ico { flex: none; width: 28px; height: 28px; border-radius: 8px; overflow: hidden;
|
||||
display: grid; place-items: center; background: #0b0e14; }
|
||||
.ico img, .ico svg { width: 100%; height: 100%; display: block; }
|
||||
.ico .letter { font-weight: 800; font-size: 15px; color: var(--acid); }
|
||||
.name { flex: 1; font-weight: 600; font-size: 13px; color: var(--ink); }
|
||||
.check { flex: none; width: 14px; height: 14px; color: var(--acid); opacity: 0; }
|
||||
.row.cur .check { opacity: 1; }
|
||||
.footer { border-top: 1px solid var(--line); padding: 4px; }
|
||||
.footer .row { color: var(--mut); font-weight: 500; }
|
||||
.footer .ico { background: transparent; }
|
||||
.footer .ico svg { width: 18px; height: 18px; color: var(--mut); }
|
||||
.empty { padding: 16px; text-align: center; color: var(--mut); font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pop" id="pop">
|
||||
<div class="hdr">AI engine</div>
|
||||
<div class="list" id="list"></div>
|
||||
<div class="footer">
|
||||
<div class="row" id="settingsRow">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div>
|
||||
<div class="name">Pick AI engines…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const api = window.llmPicker;
|
||||
const list = document.getElementById("list");
|
||||
const esc = (s) => String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
function render(state) {
|
||||
const engines = state.engines || [];
|
||||
const cur = state.current;
|
||||
list.innerHTML = "";
|
||||
if (!engines.length) {
|
||||
list.innerHTML = '<div class="empty">No AI engine enabled.</div>';
|
||||
return;
|
||||
}
|
||||
for (const e of engines) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row" + (e.id === cur ? " cur" : "");
|
||||
const ico = e.icon
|
||||
? '<img src="' + esc(e.icon) + '" alt="">'
|
||||
: '<div class="letter">' + esc((e.name || "?").slice(0, 1).toUpperCase()) + '</div>';
|
||||
row.innerHTML =
|
||||
'<div class="ico">' + ico + '</div>' +
|
||||
'<div class="name">' + esc(e.name) + '</div>' +
|
||||
'<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
row.addEventListener("click", () => api.pick(e.id));
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
document.getElementById("settingsRow").addEventListener("click", () => api.settings());
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") api.close(); });
|
||||
window.addEventListener("blur", () => api.close());
|
||||
api.onState(render);
|
||||
api.ready();
|
||||
// Report rendered size so the main process can size the WebContentsView
|
||||
// to match the pop's natural content. Measuring documentElement's scroll
|
||||
// size — the pop's own getBoundingClientRect may still be clipped by the
|
||||
// view bounds until the host resizes to our ask.
|
||||
function reportSize() {
|
||||
const w = document.documentElement.scrollWidth;
|
||||
const h = document.documentElement.scrollHeight;
|
||||
api.resize(w, h);
|
||||
}
|
||||
new ResizeObserver(reportSize).observe(document.body);
|
||||
requestAnimationFrame(reportSize);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
298
main.js
298
main.js
|
|
@ -199,7 +199,18 @@ const isFrozen = (id) => !!(SEARCH_ENGINES[id] && SEARCH_ENGINES[id].frozen);
|
|||
// site is behind a bot wall and serves no icon) keeps the emoji too.
|
||||
const ENGINE_ICON_DIR = path.join(__dirname, "engine-icons");
|
||||
const bundledEngineIcons = new Set((() => { try { return fs.readdirSync(ENGINE_ICON_DIR); } catch { return []; } })());
|
||||
const bundledIcon = (id) => (bundledEngineIcons.has(id + ".png") ? url.pathToFileURL(path.join(ENGINE_ICON_DIR, id + ".png")).href : null);
|
||||
// Picks the first match in a preferred extension order so an .svg tile can
|
||||
// override an older .png without a filename collision. SVG wins — it scales
|
||||
// to any strip/picker size without blurring.
|
||||
const BUNDLED_ICON_EXTS = ["svg", "png"];
|
||||
const bundledIcon = (id) => {
|
||||
for (const ext of BUNDLED_ICON_EXTS) {
|
||||
if (bundledEngineIcons.has(id + "." + ext)) {
|
||||
return url.pathToFileURL(path.join(ENGINE_ICON_DIR, id + "." + ext)).href;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const customIconDir = () => path.join(app.getPath("userData"), "engine-icons");
|
||||
const ICON_EXTS = ["png", "ico", "jpg", "gif", "webp", "svg"];
|
||||
function engineHost(u) { try { return new URL(String(u).replace("%s", "x")).hostname.toLowerCase(); } catch { return ""; } }
|
||||
|
|
@ -5813,6 +5824,13 @@ function createWindow() {
|
|||
raise(unlockPop);
|
||||
deferOverlayLoad(unlockPop, "unlock.html");
|
||||
unlockPop.setVisible(false);
|
||||
// Sidebar-strip's AI engine picker. Replaces the plain native menu with
|
||||
// a styled list that shows real engine icons; see quicklinks-llm-menu.
|
||||
llmPickerPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "llm-picker-preload.js") } });
|
||||
try { llmPickerPop.setBackgroundColor("#00000000"); } catch {}
|
||||
raise(llmPickerPop);
|
||||
deferOverlayLoad(llmPickerPop, "llm-picker.html");
|
||||
llmPickerPop.setVisible(false);
|
||||
// Page dialogs (alert / confirm / prompt) — see the js-dialog block.
|
||||
jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } });
|
||||
try { jsDialogPop.setBackgroundColor("#00000000"); } catch {}
|
||||
|
|
@ -5845,7 +5863,7 @@ function onLastWindowClosed() {
|
|||
// pumpApproval (and every later dapp request) until a full restart.
|
||||
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
|
||||
for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} }
|
||||
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelHeader = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null;
|
||||
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelHeader = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null; llmPickerPop = null;
|
||||
linkStatusVisible = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -7619,6 +7637,8 @@ ipcMain.handle("jsdialog-answer", (e, reqId, ok, value) => {
|
|||
});
|
||||
let approvalPop = null;
|
||||
let unlockPop = null; // vault unlock prompt (unlock.html), see requestVaultUnlock
|
||||
let llmPickerPop = null; // sidebar-strip AI engine picker (llm-picker.html)
|
||||
let llmPickerSize = { w: 240, h: 160 }; // last reported pop size for layout
|
||||
const approvalQueue = [];
|
||||
let approvalCurrent = null; // { reqId, resolve }
|
||||
let approvalSeq = 0;
|
||||
|
|
@ -9659,27 +9679,84 @@ ipcMain.handle("quicklinks-menu", (e, rect) => {
|
|||
return true;
|
||||
});
|
||||
|
||||
// LLM picker menu — right-click on the sidebar-strip's LLM button pops a
|
||||
// native menu of enabled LLM engines. Click one to set it as the default
|
||||
// AND open it in the panel; the strip button then reflects that engine.
|
||||
ipcMain.handle("quicklinks-llm-menu", (e, rect) => {
|
||||
if (!quicklinks || e.sender !== quicklinks.webContents || !primaryW?.win || primaryW.win.isDestroyed()) return false;
|
||||
// LLM picker — right-click or chevron on the sidebar-strip's LLM button
|
||||
// shows an HTML overlay (llm-picker.html) listing enabled engines with
|
||||
// their bundled icons. Click one to set it as the default AND open it in
|
||||
// the panel; the strip button then reflects that engine.
|
||||
function llmPickerState() {
|
||||
const list = enabledLlms();
|
||||
const cur = currentLlmId();
|
||||
const items = list.length ? list.map((eng) => ({
|
||||
label: eng.name,
|
||||
type: "radio",
|
||||
checked: eng.id === cur,
|
||||
click: () => { settings.defaultLlmId = eng.id; saveSettings(); emitQuickLinksState(); openQuickPanel("llm"); },
|
||||
})) : [{ label: "No AI engine enabled", enabled: false }];
|
||||
const extra = [
|
||||
{ type: "separator" },
|
||||
{ label: "Pick AI engines (Search settings)…", click: () => openSettingsTab("search") },
|
||||
];
|
||||
const menu = Menu.buildFromTemplate([...items, ...extra]);
|
||||
return {
|
||||
current: cur,
|
||||
engines: list.map((eng) => ({ id: eng.id, name: eng.name, icon: eng.favicon || "" })),
|
||||
};
|
||||
}
|
||||
function positionLlmPicker(rect) {
|
||||
if (!llmPickerPop || !primaryW?.win || primaryW.win.isDestroyed()) return;
|
||||
const r = rect || {};
|
||||
const y = Math.round(Number(r.y) || 0) + (primaryW.win === win ? CHROME_H : primaryW.chromeH || CHROME_H);
|
||||
menu.popup({ window: primaryW.win, x: Math.round((Number(r.x) || 0) + (Number(r.width) || 0)), y });
|
||||
const chromeH = primaryW.win === win ? CHROME_H : (primaryW.chromeH || CHROME_H);
|
||||
const [winW, winH] = primaryW.win.getContentSize();
|
||||
const w = Math.max(220, Math.min(360, llmPickerSize.w));
|
||||
const h = Math.max(80, Math.min(winH - chromeH - 8, llmPickerSize.h));
|
||||
const anchorX = Math.round((Number(r.x) || 0) + (Number(r.width) || 0)) + 6;
|
||||
const anchorY = Math.round(Number(r.y) || 0) + chromeH;
|
||||
const x = Math.max(0, Math.min(winW - w - 2, anchorX));
|
||||
// Anchor the popup so its bottom aligns with the LLM button and it grows
|
||||
// upward — mirroring how a start menu rises above its anchor.
|
||||
const y = Math.max(chromeH, Math.min(winH - h - 2, anchorY + (Number(r.height) || 0) - h));
|
||||
llmPickerPop.setBounds({ x, y, width: w, height: h });
|
||||
}
|
||||
ipcMain.handle("quicklinks-llm-menu", (e, rect) => {
|
||||
if (!quicklinks || e.sender !== quicklinks.webContents || !primaryW?.win || primaryW.win.isDestroyed()) return false;
|
||||
if (!llmPickerPop) return false;
|
||||
// Remember the anchor so a later resize can re-position without a new rect.
|
||||
llmPickerPop._anchor = rect || {};
|
||||
positionLlmPicker(rect);
|
||||
overlayReady(llmPickerPop).then(() => {
|
||||
try {
|
||||
llmPickerPop.webContents.send("llm-picker-state", llmPickerState());
|
||||
llmPickerPop.setVisible(true);
|
||||
raise(llmPickerPop, primaryW?.win);
|
||||
llmPickerPop.webContents.focus();
|
||||
} catch {}
|
||||
});
|
||||
return true;
|
||||
});
|
||||
function hideLlmPicker() {
|
||||
if (!llmPickerPop) return;
|
||||
try { llmPickerPop.setVisible(false); } catch {}
|
||||
}
|
||||
ipcMain.handle("llm-picker-ready", (e) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
// Re-send current state in case it changed between show and ready.
|
||||
try { llmPickerPop.webContents.send("llm-picker-state", llmPickerState()); } catch {}
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("llm-picker-pick", (e, id) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
const list = enabledLlms();
|
||||
if (!list.find((x) => x.id === id)) return false;
|
||||
settings.defaultLlmId = id; saveSettings();
|
||||
emitQuickLinksState();
|
||||
hideLlmPicker();
|
||||
openQuickPanel("llm");
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("llm-picker-settings", (e) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
hideLlmPicker();
|
||||
openSettingsTab("search");
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("llm-picker-close", (e) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
hideLlmPicker();
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("llm-picker-resize", (e, { w, h } = {}) => {
|
||||
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
|
||||
llmPickerSize = { w: Math.round(Number(w) || llmPickerSize.w), h: Math.round(Number(h) || llmPickerSize.h) };
|
||||
if (llmPickerPop._anchor) positionLlmPicker(llmPickerPop._anchor);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -10308,6 +10385,48 @@ function hermesUpdateIdentityMeta(patch) {
|
|||
}
|
||||
}
|
||||
|
||||
// Hermes-verify service: Ed25519 attestation that pins (pubkey, phone, ts)
|
||||
// to a Telegram-shared contact. See VPS/SilentMode/hermes-verify/.
|
||||
// The SPKI below is pinned at ship time; a mismatched key never verifies.
|
||||
const HERMES_VERIFY_ENDPOINT = "https://hermes.x/verify";
|
||||
const HERMES_VERIFY_SPKI_B64URL = "MCowBQYDK2VwAyEAA3td232aywvytm9KB2zKDNy-IrsRnH9AThH_wBBvKzQ";
|
||||
const HERMES_ATT_MAX_AGE_MS = 365 * 24 * 3600 * 1000;
|
||||
|
||||
function hermesB64uDecode(s) {
|
||||
s = String(s).replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (s.length % 4) s += "=";
|
||||
return Buffer.from(s, "base64");
|
||||
}
|
||||
|
||||
async function hermesVerifyAttestation(att, expectPubkeyHex, expectPhone) {
|
||||
if (!att || !att.payload || !att.sig) return false;
|
||||
try {
|
||||
const payloadBytes = hermesB64uDecode(att.payload);
|
||||
const sigBytes = hermesB64uDecode(att.sig);
|
||||
const spki = hermesB64uDecode(HERMES_VERIFY_SPKI_B64URL);
|
||||
if (spki.length !== 44) return false;
|
||||
const pubKey = nodeCrypto.createPublicKey({ key: spki, format: "der", type: "spki" });
|
||||
if (!nodeCrypto.verify(null, payloadBytes, pubKey, sigBytes)) return false;
|
||||
const payload = JSON.parse(payloadBytes.toString("utf8"));
|
||||
if (payload.v !== 1) return false;
|
||||
if (payload.pubkey !== expectPubkeyHex) return false;
|
||||
if (expectPhone != null && payload.phone !== expectPhone) return false;
|
||||
const age = Date.now() - Number(payload.ts || 0);
|
||||
if (!Number.isFinite(age) || age < 0 || age > HERMES_ATT_MAX_AGE_MS) return false;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function hermesVerifyRequest(path, { method = "GET", body } = {}) {
|
||||
const url = new URL(HERMES_VERIFY_ENDPOINT + path);
|
||||
const opts = {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : undefined,
|
||||
};
|
||||
const r = await fetch(url, body ? { ...opts, body: JSON.stringify(body) } : opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function hermesLoadIdentity() {
|
||||
try {
|
||||
if (!fs.existsSync(hermesSealedKeyPath()) || !fs.existsSync(hermesIdentityMetaPath())) return null;
|
||||
|
|
@ -10951,50 +11070,137 @@ global._hermesAPI = {
|
|||
// and hashed client-side; only the hash + user's npub leave the device.
|
||||
// Clear-text phone is kept locally so the user can see what they linked
|
||||
// and clear it; it never goes on the wire.
|
||||
// Local storage splits the phone number from its visibility:
|
||||
// meta.phone — the discoverable number currently published to
|
||||
// relays; null while the Privacy toggle is off.
|
||||
// meta.savedPhone — the number the user bound in Account; sticky
|
||||
// across toggle flips, cleared only on sign-out or
|
||||
// an explicit setPhone({forget:true}) call.
|
||||
getPhone: () => {
|
||||
const meta = hermesLoadIdentityMeta();
|
||||
return hOk({ phone: (meta && meta.phone) || null, eventIds: (meta && meta.phoneEventIds) || [] });
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
return hOk({
|
||||
phone: meta.phone || null,
|
||||
saved: meta.savedPhone || meta.phone || null,
|
||||
discoverable: !!meta.phone,
|
||||
eventIds: meta.phoneEventIds || [],
|
||||
});
|
||||
},
|
||||
setPhone: async ({ phone } = {}) => {
|
||||
setPhone: async ({ phone, forget } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
try {
|
||||
const H = await loadHermesLib();
|
||||
const e164 = H.normalisePhone(phone); // throws on bad input
|
||||
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
|
||||
const ev = await H.buildPhoneClaimEvent({ sk, phone: e164 });
|
||||
// Attach a verify-service attestation when one on file still matches
|
||||
// this exact phone. Changing the number drops the attestation.
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
let attestation = null;
|
||||
if (meta.phoneAttestation) {
|
||||
try {
|
||||
const ok = await hermesVerifyAttestation(meta.phoneAttestation, hermesState.pkHex, e164);
|
||||
if (ok) attestation = meta.phoneAttestation;
|
||||
else hermesUpdateIdentityMeta({ phoneAttestation: null, phoneVerifiedAt: null });
|
||||
} catch { hermesUpdateIdentityMeta({ phoneAttestation: null, phoneVerifiedAt: null }); }
|
||||
}
|
||||
const ev = await H.buildPhoneClaimEvent({ sk, phone: e164, attestation });
|
||||
const results = await hermesPublishEvent(ev, HERMES_DEFAULT_RELAYS);
|
||||
const accepted = results.filter((r) => r.ok).length;
|
||||
if (accepted === 0) return hErr("no relay accepted the claim — try again");
|
||||
// Persist the plaintext phone locally so the user can see what's linked
|
||||
// and retract it later. The event id goes with it so retraction can
|
||||
// reference the exact wire event we published.
|
||||
hermesUpdateIdentityMeta({
|
||||
phone: e164,
|
||||
savedPhone: forget ? null : e164,
|
||||
phoneEventIds: [ev.id, ...((hermesLoadIdentityMeta()?.phoneEventIds) || [])].slice(0, 8),
|
||||
});
|
||||
return hOk({ phone: e164, accepted, total: results.length });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
clearPhone: async () => {
|
||||
clearPhone: async ({ forget } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
try {
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
const eventIds = meta.phoneEventIds || [];
|
||||
if (!eventIds.length && !meta.phone) return hOk({ cleared: false, note: "no phone linked" });
|
||||
if (!eventIds.length && !meta.phone && !meta.savedPhone) return hOk({ cleared: false, note: "no phone linked" });
|
||||
if (eventIds.length) {
|
||||
const H = await loadHermesLib();
|
||||
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
|
||||
const ev = H.buildPhoneRetractEvent({ sk, eventIds });
|
||||
await hermesPublishEvent(ev, HERMES_DEFAULT_RELAYS);
|
||||
// Even if relays reject the deletion we clear local state — the
|
||||
// user told us to forget it, so forget it. The claim may linger on
|
||||
// relays until they expire 30078s; that's a Nostr property, not a
|
||||
// bug we can fix here.
|
||||
}
|
||||
hermesUpdateIdentityMeta({ phone: null, phoneEventIds: [] });
|
||||
// Retract the claim but keep savedPhone unless the caller said forget:
|
||||
// this lets the Privacy toggle flip back on without re-prompting.
|
||||
hermesUpdateIdentityMeta({
|
||||
phone: null,
|
||||
phoneEventIds: [],
|
||||
...(forget ? { savedPhone: null } : {}),
|
||||
});
|
||||
return hOk({ cleared: true });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
// Pure visibility switch for the Privacy toggle: reuses savedPhone so the
|
||||
// UI never has to prompt again. Returns needPhone when nothing has ever
|
||||
// been bound; the caller then opens Account.
|
||||
setPhoneDiscoverable: async ({ visible } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
if (!visible) return global._hermesAPI.clearPhone({});
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
const saved = meta.savedPhone || meta.phone;
|
||||
if (!saved) return { ok: false, needPhone: true, err: "no phone bound yet" };
|
||||
return global._hermesAPI.setPhone({ phone: saved });
|
||||
},
|
||||
|
||||
// ---- phone verification (hermes.x/verify/* + Telegram bot) ---------------
|
||||
verifyStart: async () => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
try {
|
||||
const j = await hermesVerifyRequest("/start", {
|
||||
method: "POST",
|
||||
body: { pubkey: hermesState.pkHex },
|
||||
});
|
||||
if (!j || !j.ok) return hErr(j && j.err || "verify/start failed");
|
||||
hermesUpdateIdentityMeta({ phoneVerifyNonce: j.nonce });
|
||||
return hOk({ nonce: j.nonce, deep_link: j.deep_link, expires_in_ms: j.expires_in_ms, bot: j.bot });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
verifyPoll: async () => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
const nonce = meta.phoneVerifyNonce;
|
||||
if (!nonce) return hErr("no verification in flight");
|
||||
try {
|
||||
const j = await hermesVerifyRequest("/status?nonce=" + encodeURIComponent(nonce));
|
||||
if (!j || !j.ok) return hErr(j && j.err || "verify/status failed");
|
||||
if (!j.verified) return hOk({ verified: false });
|
||||
const ok = await hermesVerifyAttestation(j.attestation, hermesState.pkHex, j.phone);
|
||||
if (!ok) return hErr("attestation did not verify (bad signature or stale)");
|
||||
hermesUpdateIdentityMeta({
|
||||
phoneAttestation: j.attestation,
|
||||
phoneVerifiedAt: j.ts || Date.now(),
|
||||
phoneVerifyNonce: null,
|
||||
savedPhone: j.phone,
|
||||
});
|
||||
// Re-publish the claim so the fresh event carries the `att` tag.
|
||||
const r = await global._hermesAPI.setPhone({ phone: j.phone });
|
||||
return hOk({ verified: true, phone: j.phone, republished: r.ok });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
verifyStatus: () => {
|
||||
const meta = hermesLoadIdentityMeta() || {};
|
||||
const att = meta.phoneAttestation;
|
||||
if (!att || !att.payload) return hOk({ verified: false, saved: meta.savedPhone || null });
|
||||
try {
|
||||
const payload = JSON.parse(hermesB64uDecode(att.payload).toString("utf8"));
|
||||
return hOk({
|
||||
verified: true,
|
||||
saved: meta.savedPhone || payload.phone,
|
||||
phone: payload.phone,
|
||||
ts: meta.phoneVerifiedAt || payload.ts,
|
||||
});
|
||||
} catch { return hOk({ verified: false, saved: meta.savedPhone || null }); }
|
||||
},
|
||||
verifyClear: () => {
|
||||
hermesUpdateIdentityMeta({ phoneAttestation: null, phoneVerifiedAt: null, phoneVerifyNonce: null });
|
||||
return hOk({ cleared: true });
|
||||
},
|
||||
findByPhones: async ({ phones } = {}) => {
|
||||
if (!Array.isArray(phones) || !phones.length) return hErr("phones[] required");
|
||||
try {
|
||||
|
|
@ -11010,9 +11216,16 @@ global._hermesAPI = {
|
|||
const dtags = Array.from(map.keys()).map((h) => H.PHONE_DTAG_PREFIX + h);
|
||||
// One REQ per relay with the full d-tag list; collect responses, newest
|
||||
// wins per (pk, d). Short timeout so bulk lookups don't hang.
|
||||
// Track hash → original phone so we can pass the expected E.164 into
|
||||
// the attestation check (prevents replaying someone else's attestation
|
||||
// across different numbers).
|
||||
const hashToPhone = new Map();
|
||||
for (const [hash, p] of map) {
|
||||
try { hashToPhone.set(hash, H.normalisePhone(p)); } catch {}
|
||||
}
|
||||
const results = await Promise.all(HERMES_DEFAULT_RELAYS.map((url) => new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
const found = []; // {dtag, pubkey, created_at}
|
||||
const found = []; // {dtag, pubkey, created_at, tags}
|
||||
const settle = () => { try { ws.close(1000); } catch {} resolve(found); };
|
||||
const t = setTimeout(settle, 5000);
|
||||
ws.on("open", () => ws.send(JSON.stringify(["REQ", "ph-" + Math.random().toString(36).slice(2, 8),
|
||||
|
|
@ -11022,7 +11235,7 @@ global._hermesAPI = {
|
|||
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 30078) {
|
||||
const d = (msg[2].tags.find((t) => t[0] === "d") || [])[1];
|
||||
if (d && d.startsWith(H.PHONE_DTAG_PREFIX)) {
|
||||
found.push({ dtag: d, pubkey: msg[2].pubkey, created_at: msg[2].created_at });
|
||||
found.push({ dtag: d, pubkey: msg[2].pubkey, created_at: msg[2].created_at, tags: msg[2].tags });
|
||||
}
|
||||
} else if (msg[0] === "EOSE") { clearTimeout(t); settle(); }
|
||||
});
|
||||
|
|
@ -11037,7 +11250,18 @@ global._hermesAPI = {
|
|||
const matches = [];
|
||||
for (const [dtag, hit] of bestByDtag) {
|
||||
const hash = dtag.slice(H.PHONE_DTAG_PREFIX.length);
|
||||
matches.push({ phone: map.get(hash) || null, pkHex: hit.pubkey, hash });
|
||||
const expectedPhone = hashToPhone.get(hash) || null;
|
||||
let verified = false;
|
||||
try {
|
||||
const attTag = (hit.tags || []).find((tg) => tg[0] === "att" && typeof tg[1] === "string" && tg[1].startsWith("tg:"));
|
||||
if (attTag) {
|
||||
const parts = attTag[1].slice(3).split(":");
|
||||
if (parts.length === 2) {
|
||||
verified = await hermesVerifyAttestation({ payload: parts[0], sig: parts[1] }, hit.pubkey, expectedPhone);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
matches.push({ phone: map.get(hash) || null, pkHex: hit.pubkey, hash, verified });
|
||||
}
|
||||
return hOk({ matches, queried: map.size });
|
||||
} catch (e) { return hErr(e); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.105",
|
||||
"version": "0.3.106",
|
||||
"license": "MPL-2.0",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue