diff --git a/engine-icons/chatgpt.svg b/engine-icons/chatgpt.svg
new file mode 100644
index 00000000..13d93f54
--- /dev/null
+++ b/engine-icons/chatgpt.svg
@@ -0,0 +1 @@
+
diff --git a/engine-icons/gemini.svg b/engine-icons/gemini.svg
new file mode 100644
index 00000000..0a1b12d5
--- /dev/null
+++ b/engine-icons/gemini.svg
@@ -0,0 +1 @@
+
diff --git a/engine-icons/grok.svg b/engine-icons/grok.svg
new file mode 100644
index 00000000..186c1d7e
--- /dev/null
+++ b/engine-icons/grok.svg
@@ -0,0 +1 @@
+
diff --git a/engine-icons/phind.svg b/engine-icons/phind.svg
new file mode 100644
index 00000000..af402d66
--- /dev/null
+++ b/engine-icons/phind.svg
@@ -0,0 +1 @@
+
diff --git a/llm-picker-preload.js b/llm-picker-preload.js
new file mode 100644
index 00000000..e7f1dc26
--- /dev/null
+++ b/llm-picker-preload.js
@@ -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)),
+});
diff --git a/llm-picker.html b/llm-picker.html
new file mode 100644
index 00000000..97f655cd
--- /dev/null
+++ b/llm-picker.html
@@ -0,0 +1,106 @@
+
+
+
+
+Pick AI
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
index 95fcad4e..526fd79a 100644
--- a/main.js
+++ b/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); }
diff --git a/package.json b/package.json
index 0af828b3..c356a1b4 100644
--- a/package.json
+++ b/package.json
@@ -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",