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 @@ +C 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 @@ +G 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 @@ +G 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 @@ +P 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 + + + +
+
AI engine
+
+ +
+ + + 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",