// Aegis wallet panel. All state comes from activate() via window.silentmode. // This file renders the multi-wallet picker, per-chain views, and collects // input; it never touches keys or the vault. const $ = (id) => document.getElementById(id); const S = window.silentmode; let state = null; // full state (all wallets + selected) let tab = "receive"; let unit = null; // "big" | "small" — chain-dependent let sendMax = false; let planTimer = null; let lastPlan = null; let settingsFilled = false; // Selected asset for the Send tab. `null` = native coin. Otherwise a // { mint, symbol, decimals } picked from the SOL wallet's SPL token list. let sendAsset = null; // Wallet strip's view mode. "coins" is the six-column ticker/chain summary; // "addresses" replaces it inline with the per-address list under one coin // group. Toggled via the group row click / the back arrow in the inline // header. Cleared whenever a fresh render is triggered by a wallet change // so the strip snaps back to the summary. let stripView = { mode: "coins", groupKey: null }; // Cached security state ({ hasPin, requirePinForSending }). Populated on // startup and refreshed after any pin/security invoke — used both by the // lock screen (PIN vs. password) and the Settings General card. let securityState = { hasPin: false, requirePinForSending: false }; let securityLoaded = false; // Cached session config: whether the vault stays unlocked across Theseus // restarts (safeStorage-backed) and how many idle minutes trigger an // auto-lock. Populated on boot; refreshed after each Settings edit. let sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true }; let sessionLoaded = false; let idleTimer = null; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); // Truncate a label to at most `n` visible chars, appending an ellipsis // when clipped. Used by the inline coin list so long user labels don't // blow out the row width; the full name stays available via title="". const shortLabel = (s, n) => { const t = String(s ?? "").trim(); const cap = Math.max(1, n || 7); return t.length > cap ? t.slice(0, cap) + "…" : t; }; const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } }; const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {}); const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, ""); // ---- coin logos ------------------------------------------------------------ // Inline SVGs so the header, wallet picker and settings surface all render // the same mark. Sized by the container via width/height attributes. function logoSvg(logo, size) { const s = size || 20; if (logo === "bch") { // All coin marks below are the canonical SVGs from // github.com/spothq/cryptocurrency-icons — the permissive-licensed // set most wallets, exchanges, and explorers standardised on, so // Aegis's logos match what users see everywhere else. Inline so // panel load doesn't fetch anything. return ``; } if (logo === "trx") { return ``; } if (logo === "sc") { return ``; } if (logo === "dgb") { return ``; } if (logo === "btc") { return ``; } if (logo === "eth") { return ``; } if (logo === "sol") { return ``; } if (logo === "aegis") { // Athena's aspis — hexagonal shield with a boss at center + four // spoke marks. Same silhouette as the aegis.x hero SVG so the wallet // and the marketing page read as one identity. return ` `; } // Fallback = Aegis shield (rather than a "?"), so an unrecognised // registry entry still looks intentional. return logoSvg("aegis", s); } function testnetTag() { return `TEST`; } // Selected wallet convenience. const sel = () => state && state.selected; const chain = () => sel()?.chain || ""; const decimals = () => sel()?.meta?.decimals || 8; const ticker = () => sel()?.meta?.ticker || ""; // Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at // 10^24-scale routinely. Use BigInt for anything that arrives as a string. function fmtBig(units, dec) { const d = dec != null ? dec : decimals(); if (typeof units === "string" && /^-?\d+$/.test(units)) { const neg = units.startsWith("-"); const raw = neg ? units.slice(1) : units; const bi = BigInt(raw || "0"); const base = 10n ** BigInt(d); const whole = (bi / base).toString(); let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, ""); // Show 8-digit precision at most for very small units; keep 2 dp minimum. const cap = Math.min(d, 8); if (frac.length > cap) frac = frac.slice(0, cap); if (!frac) frac = ""; return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); } const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d); return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); } function fmtSmall(units) { if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ","); return Number(units || 0).toLocaleString("en-US"); } function smallUnitLabel() { const c = chain(); if (c === "bch" || c === "dgb" || c === "btc") return "sat"; if (c === "trx") return "sun"; if (c === "sc") return "H"; if (c === "eth") return "wei"; if (c === "sol") return "lamports"; return "u"; } // Some chains (SOL) suffix explorer URLs to tell devnet from mainnet. function explorerHref(base, id) { const s = sel(); return base + id + (s?.explorerSuffix || ""); } function bigUnitLabel() { return ticker(); } // ---- fiat helpers ---------------------------------------------------------- // Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled // or missing, fiat helpers return null and the caller renders nothing. function priceFor(chain) { if (!state?.prices?.enabled) return null; return state.prices.prices?.[chain] ?? null; } // Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe // for wide-decimals coins (SC=24, ETH=18) that overflow Number. function usdOf(chain, units, decimals) { const price = priceFor(chain); if (price == null || !units) return null; const d = Number(decimals) || 0; if (typeof units === "string" && /^-?\d+$/.test(units)) { // BigInt-safe: divide the units by 10^d first via BigInt, then use // the fractional remainder as a Number multiplier for the last dp. const neg = units.startsWith("-"); const abs = neg ? units.slice(1) : units; const base = 10n ** BigInt(d); const bi = BigInt(abs); const whole = Number(bi / base); const frac = Number(bi % base) / Number(base); return (neg ? -1 : 1) * (whole + frac) * price; } const n = Number(units) / Math.pow(10, d); return n * price; } // Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else // grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the // feed is enabled but hasn't returned yet. function fmtFiat(usd) { if (usd == null) return null; if (usd === 0) return "$0.00"; const abs = Math.abs(usd); // Sub-cent coins (SC ~ $0.0007, DGB ~ $0.005) get 3 significant digits so // users see meaningful movement without the row screaming "< $0.01" at // every wallet. Keeps trailing zeros trimmed: $0.000756, not $0.0007560. if (abs < 0.01) { const sig = usd.toPrecision(3); const num = Number(sig); if (num === 0) return "$0"; // Node.js's toPrecision returns e.g. "0.000756" for tiny numbers, "5.60e-4" // for extreme. Normalise to a plain fixed string. const s = /e/i.test(sig) ? num.toFixed(Math.max(0, -Math.floor(Math.log10(abs)) + 2)) : sig; return "$" + s; } if (abs < 10) return "$" + usd.toFixed(2); const int = Math.floor(usd); const frac = Math.abs(usd - int).toFixed(2).slice(1); return "$" + int.toLocaleString("en-US") + frac; } function fiatSkeleton() { return state?.prices?.enabled ? "≈ $—" : null; } // ---- security: PIN encryption + verification (WebCrypto) ------------------- // The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters) // derives an AES-GCM key; the master password is encrypted with a fresh // per-blob IV. The addon (main process) only handles the opaque blob; the // panel never sends the raw PIN or the master password to it. The rate // limiter is stored addon-side so reloading the panel cannot reset it. const PIN_ITERS = 200000; const PIN_MAX_FAILS = 5; const PIN_LOCKOUT_MS = 15 * 60 * 1000; const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; }; async function pinDeriveKey(pin, saltBytes, iters) { const enc = new TextEncoder(); const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } async function pinEncryptMaster(pin, masterPassword) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await pinDeriveKey(pin, salt, PIN_ITERS); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword))); return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS }; } async function pinDecryptMaster(pin, blob) { const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct)); return new TextDecoder().decode(pt); } async function pinLockoutRemainingMs() { try { const s = await S.invoke("pinFailStatus"); if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0; const since = Date.now() - (s.last || 0); return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since); } catch { return 0; } } async function refreshSecurityState() { try { securityState = await S.invoke("securityGet"); securityLoaded = true; } catch { securityState = { hasPin: false, requirePinForSending: false }; securityLoaded = true; } return securityState; } async function refreshSessionState() { try { sessionState = await S.invoke("sessionStatus"); sessionLoaded = true; } catch { sessionState = { lockOnClose: true, idleMinutes: 15, hasSession: false, safeStorageAvailable: true }; sessionLoaded = true; } return sessionState; } // Idle auto-lock. Any user gesture in the panel resets the timer; if the // user stays quiet for `sessionState.idleMinutes`, Aegis invokes vaultLock // so a walked-away laptop doesn't leave the wallet unlocked. Wired at // boot; each config change bounces it via bindIdleAutoLock(). function bindIdleAutoLock() { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } const mins = Number(sessionState.idleMinutes) || 0; if (mins <= 0) return; const reset = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(async () => { // Only lock if the vault is actually open — no point calling lock // while we're already on the unlock screen. const s = sel(); if (!s || s.phase !== "ready") return; try { state = await S.invoke("vaultLock"); stripView = { mode: "coins", groupKey: null }; render(); } catch (e) { /* silent — user activity will retry */ } }, mins * 60 * 1000); }; reset(); // Reset on any deliberate gesture. Passive listeners so scrolling long // wallet lists doesn't fight the idle timer. const opts = { passive: true, capture: true }; const listener = () => reset(); ["mousedown", "keydown", "touchstart", "focus", "click"].forEach((ev) => document.addEventListener(ev, listener, opts)); // Store the listener so a later bindIdleAutoLock doesn't stack duplicates. if (bindIdleAutoLock._prev) { for (const ev of ["mousedown", "keydown", "touchstart", "focus", "click"]) { document.removeEventListener(ev, bindIdleAutoLock._prev, opts); } } bindIdleAutoLock._prev = listener; } // ---- tabs ------------------------------------------------------------------ document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); function showTab(name) { tab = name; document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name)); document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; }); if (name === "settings") { settingsFilled = false; fillSettings(); } if (name === "send") applyUnitPicker(); // Settings is the only tab that can be reached while the vault is // locked. Re-run the full render() so the lock-screen overlay + chrome // visibility stay in sync with whichever tab the user just picked. render(); } // ---- wallet picker (two-step add) ------------------------------------------ $("pickerBtn").addEventListener("click", (e) => { // The header still doubles as a quick "edit this wallet" click target — // clicking anywhere on the wallet name/badge opens the manage modal for // the selected wallet. The dedicated ✎ chip on the right does the same // thing more explicitly. Clicking either the + Add or ⋯ More chip skips // this handler because those chips have their own click handlers that // stopPropagation, so they never accidentally re-open manage. if (e.target && e.target.closest("#hAdd, #hMore")) return; const sel_ = sel(); if (!sel_) return; const w = (state?.wallets || []).find((x) => x.id === state.selectedWalletId); if (!w) return; openWalletManageModal(w); e.stopPropagation(); }); // + Add and ⋯ More chips moved from the wallet strip into the header // (0.6.31). Same handlers as before — fillPicker for the Add-only picker, // openMoreMenu for Import/Connect/About. Each stopsPropagation so the // outer pickerBtn click doesn't also fire "manage this wallet". $("hAdd").addEventListener("click", (e) => { e.stopPropagation(); pickerTab = "add"; const d = $("drop"); d.hidden = false; fillPicker(); }); $("hMore").addEventListener("click", (e) => { e.stopPropagation(); openMoreMenu(); }); document.addEventListener("click", (e) => { const d = $("drop"); if (d.hidden) return; // Also whitelist the always-visible wallet strip so its + / ⋯ buttons — // which run fillPicker() and detach themselves during the render — don't // trigger the outer "click outside → close" logic. Before this whitelist // the Add button appeared broken because the picker opened and immediately // closed in the same event tick. if (e.target.closest("#drop") || e.target.closest("#pickerBtn") || e.target.closest("#walletStrip")) return; d.hidden = true; }); // Which picker tab is showing. Persisted in the picker instance state so a // user who opens the picker → picks Import → cancels → reopens returns to // Wallets (the sane default). let pickerTab = "wallets"; function fillPicker() { const d = $("drop"); const wallets = state?.wallets || []; const coins = state?.coins || []; const rowsHtml = wallets.map((w) => { const on = w.id === state.selectedWalletId ? "on" : ""; const totalUnits = w.balance ? (typeof w.balance.confirmed === "string" ? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString() : (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0; const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—"; const usd = usdOf(w.chain, totalUnits, w.decimals); const fiat = fmtFiat(usd); const fiatLine = fiat ? `
${esc(fiat)}
` : ""; const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`; const importedTag = w.kind === "imported" ? ` IMPORTED` : ""; // Derivation path under the balance — one of the most requested pieces of // info for anyone verifying an address against another wallet. Legacy / // isDefault wallets can't be removed (they gate legacy funds). const pathLine = w.accountPath ? `
${esc(w.accountPath)}
` : ""; const menu = w.isLegacy || w.isDefault ? `🔒` : ``; return `
${logoSvg(w.logo, 22)}
${esc(w.label)}${importedTag}
${sub}
${pathLine}
${esc(bal)}
${fiatLine}
${menu}
`; }).join(""); // "Add wallet" is a two-step flyout: first show coins, then that coin's // networks. Nothing is created until the user clicks a specific network. const coinRows = coins.map((c) => { const testCount = c.networks.filter((n) => n.testnet).length; const sub = c.networks.length > 1 ? c.networks.map((n) => n.label).join(" · ") : c.networks[0].label; return `
${logoSvg(c.logo, 22)}
${esc(c.label)}
${esc(sub)}
`; }).join(""); // Three-tab layout: Add (create new) / Import (external) / Connect // (WizardConnect pairing). The old Wallets tab is gone — the always-visible // strip above the header owns switching, so the picker no longer needs to // duplicate that list. Add-only when the picker opens from [+]. const bchWallets = wallets.filter((w) => w.chain === "bch"); const wcCount = Object.values(state?.wc || {}).reduce((n, arr) => n + (arr?.length || 0), 0); if (pickerTab === "wallets") pickerTab = "add"; // migrate any stale default d.innerHTML = `
Creates a new wallet derived from your Theseus vault. Pick a coin, then a network.
${coinRows}
Load an existing wallet by pasting its BIP39 mnemonic + derivation path, or a WIF private key. Key material is stored encrypted in Theseus's wallet-imports.enc.
${logoSvg("aegis", 22)}
Bulk-import from encrypted keystore
Deviant chipnet-keystore.json (or any chipnet-keystore/2-encrypted file) — master password unlocks all wallets in one go
${logoSvg("aegis", 22)}
Import a single wallet (any coin)
BIP39 mnemonic + path, or a chain-native private key (WIF / hex / base58)
${renderConnectPane(bchWallets)}
`; // Tab switching stays inside the picker — never triggers a state emit. // stopPropagation because the click re-renders innerHTML: the tab element // becomes detached, and the outer document handler (which hides the picker // when a click lands outside #drop) then sees a disconnected target and // dismisses the whole panel. Same reason the import row needs it below. d.querySelectorAll("[data-ptab]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); pickerTab = b.dataset.ptab; fillPicker(); })); const closeBtn = d.querySelector("#pickerClose"); if (closeBtn) closeBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; }); if (pickerTab === "connect") wireConnectPane(); d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => { d.hidden = true; try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); } catch (e) { showErr(cleanErr(e)); } })); // Per-row "⋯" menu — rename + remove. Removes call the same handler the // Settings tab uses; a hard confirm gates any accidental click since the // action is unrecoverable for the wallet's local metadata (funds stay // on-chain; the pointer is what disappears). d.querySelectorAll("[data-walletmenu]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); const id = b.dataset.walletmenu; const w = (state?.wallets || []).find((x) => x.id === id); if (!w) return; openWalletManageModal(w); })); d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => { // Collapse other coins' network groups; toggle this one. d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; }); d.querySelectorAll(".coinrow .caret").forEach((c) => { c.textContent = "▸"; }); const group = d.querySelector("#netgroup-" + r.dataset.coin); group.hidden = !group.hidden; r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾"; })); d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => { const [c, n] = r.dataset.add.split(":"); d.hidden = true; try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); } catch (e) { showErr(cleanErr(e)); } })); const impBtn = $("picker-import-single"); if (impBtn) impBtn.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openImportModal(null); }); const impKs = $("picker-import-keystore"); if (impKs) impKs.addEventListener("click", (e) => { e.stopPropagation(); d.hidden = true; openKeystoreImportModal(); }); } // Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label // + category. Backend derives cashaddr and stores signer material in // wallet-imports.enc (design §3.2). Modal is a plain overlay div injected // into the panel body so it works over any tab. // Manage-wallet modal: rename + derivation path + hard remove. Backend // handlers already exist (renameWallet, setAccountPath, removeWallet); this // just gives them a UI in the picker so users don't dive into per-wallet // Settings for something they view as a top-level action. function openWalletManageModal(w) { const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:24px"; const canRemove = !(w.isDefault || w.isLegacy); const canSetPath = ["bch", "btc", "dgb"].includes(w.chain); overlay.innerHTML = `
${logoSvg(w.logo, 22)}
Manage: ${esc(w.label)}
${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " · testnet" : ""}
Label
${canSetPath ? `
Derivation path (account)
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
` : ""}
${canRemove ? `` : `Default wallet — cannot be removed.`}
`; document.body.appendChild(overlay); const close = () => { try { overlay.remove(); } catch {} }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.querySelector("#mwClose").addEventListener("click", close); overlay.querySelector("#mwCancel").addEventListener("click", close); overlay.querySelector("#mwSave").addEventListener("click", async () => { const msg = overlay.querySelector("#mwMsg"); msg.hidden = true; const nextLabel = overlay.querySelector("#mwLabel").value.trim(); const nextPath = overlay.querySelector("#mwPath")?.value?.trim(); try { if (nextLabel && nextLabel !== w.label) { state = await S.invoke("renameWallet", { id: w.id, label: nextLabel }); } if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) { state = await S.invoke("setAccountPath", { id: w.id, accountPath: nextPath }); } close(); fillPicker(); render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); if (canRemove) overlay.querySelector("#mwRemove").addEventListener("click", async () => { const msg = overlay.querySelector("#mwMsg"); msg.hidden = true; if (!confirm(`Remove "${w.label}" from Aegis?\n\nOn-chain funds stay where they are — this only unlinks the wallet from Aegis. Add it back later on the same coin + network to derive the same addresses (${w.kind === "imported" ? "or re-import if this was imported" : "from your vault seed"}).`)) return; try { state = await S.invoke("removeWallet", { id: w.id }); close(); fillPicker(); render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); } // Master-key bulk import (MASTER-KEY-INTEGRATION.md §7.1). // The user picks a chipnet-keystore/2-encrypted JSON file + types the master // password. Decrypt runs entirely in the panel iframe via SubtleCrypto; the // password never crosses IPC or the network. Preview shows cashaddr + label // + category for each entry; user picks with checkboxes and hits Import. // Rate limit: 5 fails / 60 s → 30 s lockout (§8.6). Chipnet-only (§8.7 — // rejects `bitcoincash:` prefixes silently). let keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 }; function hexToBytesU8(h) { const s = String(h || ""); const out = new Uint8Array(s.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); return out; } async function unlockKeystoreV2(encryptedJson, passphrase) { if (encryptedJson.spec !== "chipnet-keystore/2-encrypted") { throw new Error("wrong password"); // opaque — actual reason is bad file } const enc = new TextEncoder(); const salt = hexToBytesU8(encryptedJson.kdf.salt); const iv = hexToBytesU8(encryptedJson.encryption.iv); const cipherAll = hexToBytesU8(encryptedJson.ciphertext); const passKey = await crypto.subtle.importKey("raw", enc.encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"]); const aesKey = await crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: encryptedJson.kdf.iterations, hash: "SHA-256" }, passKey, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipherAll); return JSON.parse(new TextDecoder().decode(ptBuf)); } function openKeystoreImportModal() { const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px"; overlay.innerHTML = `
${logoSvg("aegis", 22)}
Bulk-import from encrypted keystore
Chipnet only. Master password never leaves this panel — it decrypts the file locally via WebCrypto. Every imported wallet lands in Theseus's wallet-imports.enc, no plaintext on disk.
Keystore file
Typically Deviant/Keys/chipnet-keystore.json. Any chipnet-keystore/2-encrypted file works.
Master password
`; document.body.appendChild(overlay); const close = () => { try { overlay.remove(); } catch {} }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.querySelector("#ksClose").addEventListener("click", close); overlay.querySelector("#ksCancel").addEventListener("click", close); // Loaded keystore file (parsed JSON) and the decrypted plaintext once // the user unlocks it. Kept in this closure so nothing hits IPC. let loadedFile = null; let decrypted = null; const setMsg = (t, cls = "err") => { const el = overlay.querySelector("#ksMsg"); if (!t) { el.hidden = true; return; } el.className = "msg " + cls; el.textContent = t; el.hidden = false; }; overlay.querySelector("#ksFile").addEventListener("change", async (e) => { setMsg(""); const file = e.target.files?.[0]; if (!file) { loadedFile = null; return; } if (file.size > 512 * 1024) { setMsg("File is too large for a keystore (>512 KB)."); loadedFile = null; return; } try { const text = await file.text(); loadedFile = JSON.parse(text); if (loadedFile?.spec !== "chipnet-keystore/2-encrypted") { setMsg("File is not a chipnet-keystore/2-encrypted."); loadedFile = null; return; } } catch (er) { setMsg("File is not valid JSON."); loadedFile = null; } }); overlay.querySelector("#ksUnlock").addEventListener("click", async () => { setMsg(""); // Rate-limit check first (§8.6). const now = Date.now(); if (keystoreUnlockFails.lockedUntil && now < keystoreUnlockFails.lockedUntil) { const secs = Math.ceil((keystoreUnlockFails.lockedUntil - now) / 1000); setMsg(`Too many failed attempts — try again in ${secs}s.`); return; } if (!loadedFile) { setMsg("Pick a keystore file first."); return; } const pass = overlay.querySelector("#ksPass").value; if (!pass) { setMsg("Enter the master password."); return; } const btn = overlay.querySelector("#ksUnlock"); btn.disabled = true; const orig = btn.textContent; btn.textContent = "Decrypting…"; try { decrypted = await unlockKeystoreV2(loadedFile, pass); // Reset failure counter on success (§8.6). keystoreUnlockFails = { count: 0, firstAt: 0, lockedUntil: 0 }; renderKeystorePreview(overlay, decrypted); } catch (err) { // Opaque error (§8.5). Track failure for rate-limit. if (!keystoreUnlockFails.firstAt || now - keystoreUnlockFails.firstAt > 60_000) { keystoreUnlockFails = { count: 1, firstAt: now, lockedUntil: 0 }; } else { keystoreUnlockFails.count++; if (keystoreUnlockFails.count >= 5) { keystoreUnlockFails.lockedUntil = now + 30_000; setMsg("5 failed attempts. Locked for 30 seconds."); } } if (!keystoreUnlockFails.lockedUntil) setMsg("Wrong password."); } finally { btn.disabled = false; btn.textContent = orig; } }); overlay.querySelector("#ksImport").addEventListener("click", async () => { setMsg(""); const rows = [...overlay.querySelectorAll("[data-ksrow]")].filter((r) => r.querySelector("input[type=checkbox]").checked); if (!rows.length) { setMsg("Select at least one wallet to import."); return; } const btn = overlay.querySelector("#ksImport"); btn.disabled = true; const orig = btn.textContent; btn.textContent = "Importing…"; let ok = 0, skipped = 0, errors = []; for (const row of rows) { const slug = row.dataset.ksrow; const entry = decrypted?.wallets?.[slug]; if (!entry) { errors.push(`${slug}: missing in decrypted payload`); continue; } // Chipnet-only guard (§8.7). Refuse mainnet. if (String(entry.cashaddr || "").startsWith("bitcoincash:")) { skipped++; continue; } if (!String(entry.cashaddr || "").startsWith("bchtest:")) { skipped++; continue; } const spec = { chain: "bch", network: "chipnet", label: entry.label || slug, category: entry.category || "operational", source: entry.source || `keystore-bulk-import#${slug}` }; if (entry.wif) { spec.wif = entry.wif; } else if (entry.seed) { // Deviant's keystore stores `seed` as either raw hex (fromMasterSeed // path) or a BIP39 mnemonic (word list). Route based on shape. const s = String(entry.seed).trim(); if (/^[0-9a-f]{64,128}$/i.test(s)) { spec.seedHex = s; spec.path = entry.path; } else { spec.mnemonic = s; spec.path = entry.path; } } else { errors.push(`${slug}: no wif or seed`); continue; } try { await S.invoke("importWallet", spec); ok++; } catch (er) { const msg = cleanErr(er); // Duplicate imports are non-errors: user re-ran on the same file. if (/duplicate/i.test(msg)) { skipped++; continue; } errors.push(`${slug}: ${msg}`); } } btn.textContent = orig; btn.disabled = false; if (errors.length) { setMsg(`Imported ${ok}, ${skipped} skipped (mainnet). ${errors.length} error(s): ${errors.slice(0, 3).join("; ")}${errors.length > 3 ? "…" : ""}`); } else if (ok) { // Session pw is dropped when the overlay closes; we don't hold it. close(); // Refresh panel state so the wallet strip shows the new imports. try { state = await S.invoke("state"); render(); } catch {} } else { setMsg(`Nothing imported${skipped ? ` — ${skipped} mainnet entries skipped (chipnet-only)` : ""}.`); } }); } function renderKeystorePreview(overlay, plain) { const wallets = plain?.wallets || {}; const entries = Object.entries(wallets).map(([slug, w]) => ({ slug, cashaddr: String(w.cashaddr || ""), label: w.label || slug, category: w.category || "operational", kind: w.wif ? "wif" : (w.seed ? "seed" : "?"), })); const chipnet = entries.filter((e) => e.cashaddr.startsWith("bchtest:")); const mainnet = entries.filter((e) => e.cashaddr.startsWith("bitcoincash:")); const el = overlay.querySelector("#ksList"); el.innerHTML = chipnet.map((e) => ``).join(""); const meta = `${chipnet.length} chipnet wallets available.` + (mainnet.length ? ` ${mainnet.length} mainnet entries hidden (chipnet-only import).` : ""); overlay.querySelector("#ksPreviewMeta").textContent = meta; overlay.querySelector("#ksPreview").hidden = false; overlay.querySelector("#ksUnlock").hidden = true; overlay.querySelector("#ksImport").hidden = false; overlay.querySelector("#ksFileField").style.display = "none"; overlay.querySelector("#ksPassField").style.display = "none"; overlay.querySelector("#ksSelAll").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = true)); overlay.querySelector("#ksSelNone").addEventListener("click", () => el.querySelectorAll("input[type=checkbox]").forEach((c) => c.checked = false)); overlay.querySelector("#ksSelBns").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => { const cat = r.querySelector(".ttag")?.textContent || ""; r.querySelector("input[type=checkbox]").checked = cat === "bns" || cat === "bns-infra"; })); overlay.querySelector("#ksSelOps").addEventListener("click", () => el.querySelectorAll("[data-ksrow]").forEach((r) => { const cat = r.querySelector(".ttag")?.textContent || ""; r.querySelector("input[type=checkbox]").checked = cat === "operational"; })); } // Multi-chain import config — drives the form shape per coin. Every entry // declares: label / logo / networks (with default derivation path) / // key-material formats accepted / placeholder for the raw-key input. const IMPORT_COIN_CONFIG = { bch: { label: "Bitcoin Cash", logo: "bch", networks: [ { id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/1'/0'/0/0", testnet: true }, { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" }, ], }, btc: { label: "Bitcoin", logo: "btc", // Testnet3 is de facto abandoned (blocks stall for weeks, faucets // dried up); Signet is Bitcoin's living testnet now. Only Signet is // exposed to new imports. The testnet3 adapter is kept in // lib/chain-btc.js so any wallet created on an earlier version still // loads — it just no longer appears in the picker. networks: [ { id: "mainnet", label: "Mainnet", defaultPath: "m/84'/0'/0'/0/0" }, { id: "signet", label: "Signet", defaultPath: "m/84'/1'/0'/0/0", testnet: true }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "wif", label: "WIF private key", placeholder: "Kx… / Lz… / cN… (base58check)" }, ], }, dgb: { label: "DigiByte", logo: "dgb", networks: [ { id: "mainnet", label: "Mainnet", defaultPath: "m/84'/20'/0'/0/0" }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "wif", label: "WIF private key", placeholder: "L… / K… (base58check)" }, ], }, eth: { label: "Ethereum", logo: "eth", networks: [ { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/60'/0'/0/0" }, { id: "sepolia", label: "Sepolia", defaultPath: "m/44'/60'/0'/0/0", testnet: true }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" }, ], }, trx: { label: "Tron", logo: "trx", networks: [ { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/195'/0'/0/0" }, { id: "nile", label: "Nile testnet", defaultPath: "m/44'/195'/0'/0/0", testnet: true }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "privHex", label: "Private key (32-byte hex)", placeholder: "0x…" }, ], }, sol: { label: "Solana", logo: "sol", networks: [ { id: "mainnet", label: "Mainnet-beta", defaultPath: "m/44'/501'/0'/0'" }, { id: "devnet", label: "Devnet", defaultPath: "m/44'/501'/0'/0'", testnet: true }, ], formats: [ { id: "mnemonic", label: "BIP39 mnemonic + path" }, { id: "privHex", label: "Private key (hex)", placeholder: "32 or 64 bytes hex" }, { id: "privB58", label: "Private key (base58)", placeholder: "Phantom / Solflare export" }, ], }, }; function openImportModal(initialChain) { const chains = Object.keys(IMPORT_COIN_CONFIG); let curChain = chains.includes(initialChain) ? initialChain : "bch"; const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:16px"; overlay.innerHTML = `
Import a wallet
Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.
Coin
Network
Source
Mnemonic (12/24 words)
Derivation path
Label
Category
`; document.body.appendChild(overlay); const close = () => { try { overlay.remove(); } catch {} }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.querySelector("#imClose").addEventListener("click", close); overlay.querySelector("#imCancel").addEventListener("click", close); const netGroup = overlay.querySelector("#imNetworkGroup"); const fmtGroup = overlay.querySelector("#imFormatGroup"); const rawField = overlay.querySelector("#imRawField"); const mnField = overlay.querySelector("#imMnemonicField"); function paintChain() { const cfg = IMPORT_COIN_CONFIG[curChain]; overlay.querySelector("#imHeaderLogo").innerHTML = logoSvg(cfg.logo, 22); netGroup.innerHTML = cfg.networks.map((n, i) => ``).join(""); fmtGroup.innerHTML = cfg.formats.map((f, i) => ``).join(""); overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", updatePathDefault)); overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", updateFormatFields)); updatePathDefault(true); updateFormatFields(); } function updatePathDefault(force) { const cfg = IMPORT_COIN_CONFIG[curChain]; const netId = overlay.querySelector('input[name="imNet"]:checked')?.value; const net = cfg.networks.find((n) => n.id === netId) || cfg.networks[0]; const path = overlay.querySelector("#imPath"); if (force || !path.value.trim()) path.value = net.defaultPath; overlay.querySelector("#imPathHint").textContent = `Default for ${net.label}: ${net.defaultPath}`; } function updateFormatFields() { const cfg = IMPORT_COIN_CONFIG[curChain]; const fmt = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; const f = cfg.formats.find((x) => x.id === fmt) || cfg.formats[0]; mnField.hidden = fmt !== "mnemonic"; rawField.hidden = fmt === "mnemonic"; if (fmt !== "mnemonic") { overlay.querySelector("#imRawLabel").textContent = f.label; overlay.querySelector("#imRaw").placeholder = f.placeholder || ""; overlay.querySelector("#imRaw").value = ""; } } overlay.querySelector("#imCoin").addEventListener("change", (e) => { curChain = e.target.value; paintChain(); }); paintChain(); overlay.querySelector("#imGo").addEventListener("click", async () => { const msg = overlay.querySelector("#imMsg"); msg.hidden = true; const chain = curChain; const network = overlay.querySelector('input[name="imNet"]:checked')?.value; const kind = overlay.querySelector('input[name="imKind"]:checked')?.value || "mnemonic"; const label = overlay.querySelector("#imLabel").value.trim(); const category = overlay.querySelector("#imCategory").value; if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; } const payload = { chain, network, label, category }; if (kind === "mnemonic") { payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim(); payload.path = overlay.querySelector("#imPath").value.trim(); if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; } } else if (kind === "wif") { payload.wif = overlay.querySelector("#imRaw").value.trim(); if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; } } else if (kind === "privHex") { payload.privHex = overlay.querySelector("#imRaw").value.trim(); if (!payload.privHex) { msg.textContent = "Private key hex required."; msg.hidden = false; return; } } else if (kind === "privB58") { payload.privB58 = overlay.querySelector("#imRaw").value.trim(); if (!payload.privB58) { msg.textContent = "Private key base58 required."; msg.hidden = false; return; } } try { state = await S.invoke("importWallet", payload); close(); render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); } // Content of the Connect pane in the picker — WizardConnect pairing lives // here so users can paste a wiz:// URI without diving into per-wallet // Settings. If no BCH wallet is ready, we show a gate instead of the form. function renderConnectPane(bchWallets) { const readyBch = bchWallets.filter((w) => w.phase === "ready"); if (!readyBch.length) { return `
WizardConnect pairs Aegis with a BCH dapp (Cauldron, Moria, or any site built on the SDK).
${bchWallets.length ? "Unlock your password vault first — WizardConnect uses your BCH keys to sign." : "Add a BCH wallet first via the Add tab, then come back."}
`; } const options = readyBch.map((w) => ``).join(""); // Flatten all connected dapps (across BCH wallets) into one list — the // user thinks "my dapps", not "dapps per wallet". const rows = []; for (const w of readyBch) { const conns = state?.wc?.[w.id] || []; for (const c of conns) rows.push({ ...c, walletId: w.id, walletLabel: w.label }); } const rowsHtml = rows.length ? rows.map((c) => `
${c.dappIcon ? `` : ""}
${esc(c.dappName || "(pairing…)")}
on ${esc(c.walletLabel)} · ${esc((c.uri || "").slice(0, 40))}…
`).join("") : `
No dapps paired yet.
`; return `
Paste a wiz:// URI from a BCH dapp's Connect dialog. Aegis will sign every request after your approval.
Sign with
Paired dapps
${rowsHtml}
`; } function wireConnectPane() { const btn = document.getElementById("pkConnectBtn"); if (!btn) return; btn.addEventListener("click", async () => { const walletId = document.getElementById("pkConnectWallet").value; const uri = document.getElementById("pkConnectUri").value.trim(); const msg = document.getElementById("pkConnectMsg"); msg.hidden = true; if (!uri) return; try { state = await S.invoke("wcConnect", { walletId, uri }); document.getElementById("pkConnectUri").value = ""; fillPicker(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => { const [walletId, connId] = b.dataset.wcpick.split("|"); try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); } catch (e) { const m = document.getElementById("pkConnectMsg"); m.textContent = cleanErr(e); m.hidden = false; } })); } // Always-visible wallet strip at the top of the panel. Each existing wallet // is a chip (click to switch). Trailing [+] opens the Add-only picker; // trailing [⋯] opens Import / Connect / Manage. Existing wallets are NEVER // duplicated inside the picker — the picker is for creation flows only now. // Which coin groups are collapsed in the wallet strip. Persisted per-user in // panel-scoped session state; not durable across restarts because the picker // already opens on the currently-selected wallet's group (auto-expand below). const collapsedGroups = new Set(); // Per-chain "which network is showing" pointer. Rows are now grouped by // chain alone (BCH, BTC, ETH, …) and this map picks which subnetwork's // wallets the row surfaces. Missing entry → pickDefaultNetwork() below // prefers mainnet when present, falls back to the first wallet's network. // Session-only; a reload resets to defaults so the strip never quietly // hides a mainnet balance behind a stale testnet selection. const activeNetworkByChain = new Map(); // Legacy per-chain+network key, kept because stripView.groupKey (inline // address view) still uses it, and reorderWallets writes wallet order // grouped by it below. function subgroupKeyFor(w) { return `${w.chain}:${w.network}`; } // Short network suffix. Used both as the pill next to the ticker (when // the active network is not mainnet) and inside the network-picker // dropdown. Empty string means "call this network Mainnet in the menu" // and skip the pill on the row itself. const NETWORK_SHORT = { "bch:mainnet": "", "bch:chipnet": "Chipnet", "btc:mainnet": "", "btc:testnet3": "Testnet", "btc:signet": "Signet", "eth:mainnet": "", "eth:sepolia": "Sepolia", "trx:mainnet": "", "trx:nile": "Nile", "sol:mainnet": "", "sol:devnet": "Devnet", "dgb:mainnet": "", "sc:mainnet": "", }; function isTestnetNetwork(chain, network) { return network !== "mainnet"; } function networkLabelFor(chain, network, fallback) { const key = `${chain}:${network}`; const short = NETWORK_SHORT[key]; if (short !== undefined) return short || "Mainnet"; return fallback || network || "Mainnet"; } // Sort order inside the network dropdown: mainnet first, then the rest // in the order they appeared in the wallet list. Keeps the natural // primary-first reading while never surprising the user with alpha sort. function orderNetworks(nets) { const out = []; if (nets.includes("mainnet")) out.push("mainnet"); for (const n of nets) if (n !== "mainnet" && !out.includes(n)) out.push(n); return out; } function pickDefaultNetwork(nets) { if (nets.includes("mainnet")) return "mainnet"; return nets[0]; } // Meta for a chain-level group. Depends on which subnetwork is active, // so pass that in explicitly (renderWalletStrip has already resolved it). function chainMetaFor(sampleWallet, activeNet) { const w = sampleWallet; const short = networkLabelFor(w.chain, activeNet, w.networkLabel); const isMainnet = activeNet === "mainnet"; const isChipnet = w.chain === "bch" && activeNet === "chipnet"; return { coinName: isMainnet ? w.ticker : `${w.ticker} ${short}`, ticker: w.ticker, networkShort: isMainnet ? "" : short, networkLabel: short, logo: w.logo, testnet: !isMainnet, chipnet: isChipnet, chain: w.chain, activeNetwork: activeNet, }; } function walletBalanceUnits(w) { if (!w.balance) return 0; if (typeof w.balance.confirmed === "string") { return (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString(); } return (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0); } // Sum a group's balances into a single native-unit amount + fiat. BigInt- // safe for SC/ETH-scale decimals (24, 18) via string paths. function sumGroupUnits(gw) { let bigTotal = null; let numTotal = 0; for (const w of gw) { if (!w.balance) continue; if (typeof w.balance.confirmed === "string" || typeof w.balance.unconfirmed === "string") { const u = BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0"); bigTotal = (bigTotal == null ? u : bigTotal + u); } else { numTotal += (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0); } } if (bigTotal != null) return bigTotal.toString(); return numTotal; } function renderWalletStrip() { const el = $("walletStrip"); if (!el) return; const wallets = state?.wallets || []; const selId = state?.selectedWalletId; // Bucket wallets by chain, then by network inside each chain. Order // within a chain follows first-seen wallet, but the strip renders the // active subnetwork's slice — see activeNetworkByChain above. const chainGroups = new Map(); for (const w of wallets) { if (!chainGroups.has(w.chain)) { chainGroups.set(w.chain, { chain: w.chain, byNet: new Map(), all: [] }); } const g = chainGroups.get(w.chain); if (!g.byNet.has(w.network)) g.byNet.set(w.network, []); g.byNet.get(w.network).push(w); g.all.push(w); } // Inline addresses view still keys off `chain:network` — it lists the // wallets under one specific subnetwork, not the whole chain — so its // logic below stays subgroup-scoped. if (stripView.mode === "addresses" && stripView.groupKey) { const [subChain, subNet] = stripView.groupKey.split(":"); const cg = chainGroups.get(subChain); const gw = cg?.byNet.get(subNet) || null; if (!gw || !gw.length) { stripView = { mode: "coins", groupKey: null }; } else { const meta = chainMetaFor(gw[0], subNet); return renderInlineCoinList(el, stripView.groupKey, { meta, wallets: gw }); } } const rows = []; for (const [chain, cg] of chainGroups) { const nets = orderNetworks(Array.from(cg.byNet.keys())); let active = activeNetworkByChain.get(chain); if (!active || !nets.includes(active)) active = pickDefaultNetwork(nets); const gw = cg.byNet.get(active) || []; const meta = chainMetaFor(gw[0], active); const subKey = `${chain}:${active}`; const groupHasSel = gw.some((w) => w.id === selId); const unitPrice = priceFor(chain); const priceTxt = unitPrice != null ? fmtFiat(unitPrice) : "—"; const totalUnits = sumGroupUnits(gw); const decimals = gw[0].decimals || 8; // Always render a number — 0 balances read as "0", not "—". Users // seeing a dash next to a coin they just added assume Aegis failed // to fetch; a clean "0" makes the "adapter connected, wallet just // empty" state obvious. The em-dash still shows before the first // fetch resolves, when the adapter hasn't emitted at all. const totalNative = fmtBig(totalUnits || 0, decimals); const totalUsd = usdOf(chain, totalUnits || 0, decimals); const totalFiat = totalUsd != null ? fmtFiat(totalUsd) : ""; // Network pill sits inline with the ticker when the active network // isn't mainnet. Chipnet gets the acid tint (BCH's friendly // testnet); every other testnet uses amber. Mainnet renders no pill // so the row stays visually quiet. let pill = ""; if (meta.testnet) { const cls = meta.chipnet ? "wchipnet" : "wtestnet"; const tip = `${meta.networkLabel} — testnet, coins have no market value`; pill = `${esc(meta.networkLabel)}`; } // ▾ chevron shown only when the chain has more than one network — // otherwise the click affordance would be a lie and the ticker acts // like plain text. const multiNet = nets.length > 1; const chevron = multiNet ? `` : ""; const nameCls = multiNet ? "wcname wswitchable" : "wcname"; const nameTitle = multiNet ? `Switch network — ${nets.map((n) => networkLabelFor(chain, n, n)).join(" / ")}` : meta.coinName; // Single wallet under the active network → click selects it. // Multiple → click opens the inline addresses list scoped to that // subnetwork. const single = gw.length === 1; const walletId = single ? gw[0].id : null; const clickAction = single ? `data-wstripid="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; const walletsChip = single ? "" : `${gw.length}`; const editAttr = single ? `data-wedit="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; const setAttr = single ? `data-wsettings="${esc(walletId)}"` : `data-openlist="${esc(subKey)}"`; rows.push(`
${esc(meta.ticker)} ${chevron} ${pill} ${walletsChip} ${esc(priceTxt)} ${esc(totalNative)} ${totalFiat ? `${esc(totalFiat)}` : ""}
`); } // + Add / ⋯ More moved to the header's picker-actions in 0.6.31 — the // strip now starts directly with coin rows, no waddwrap taking up space // for buttons the user was already reaching for at the top of the panel. el.innerHTML = rows.join(""); // Row body / ticker cell click → select single wallet or open list modal. // We use event delegation via .wrow: check target inside for wact // buttons AND the network picker first (they have their own handling) // before doing the row action, so pressing ✎ or ⚙ or the ▾ chevron // never accidentally re-selects the wallet. el.querySelectorAll(".wrow").forEach((row) => row.addEventListener("click", async (e) => { if (e.target.closest(".wact")) return; if (e.target.closest(".wcname.wswitchable")) return; if (row.dataset.wstripid) { const id = row.dataset.wstripid; if (id === selId) return; try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); } catch (er) { showErr(cleanErr(er)); } } else if (row.dataset.openlist) { stripView = { mode: "addresses", groupKey: row.dataset.openlist }; renderWalletStrip(); } })); // Ticker click on a multi-network chain → pop the network dropdown. el.querySelectorAll(".wcname.wswitchable").forEach((cell) => cell.addEventListener("click", (e) => { e.stopPropagation(); const chain = cell.dataset.netpicker; const cg = chainGroups.get(chain); if (!cg) return; openNetworkPicker(cell, chain, cg); })); el.querySelectorAll(".wact[data-wedit]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); const w = (state?.wallets || []).find((x) => x.id === b.dataset.wedit); if (w) openWalletManageModal(w); })); el.querySelectorAll(".wact[data-wsettings]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); const id = b.dataset.wsettings; try { if (id !== state?.selectedWalletId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; } showTab("settings"); render(); } catch (er) { showErr(cleanErr(er)); } })); el.querySelectorAll(".wact[data-openlist]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); stripView = { mode: "addresses", groupKey: b.dataset.openlist }; renderWalletStrip(); })); // Drag & drop to reorder chains. Wallets sharing a chain stay contiguous // regardless of subnetwork — every wallet under BCH moves as one block, // mainnet + chipnet together — because the strip now presents one row // per chain. The reorder is optimistic-persistent: we call reorderWallets, // the addon writes storage, and the returned state re-renders the strip // in the new order. wireStripDragDrop(el, chainGroups); } // Anchored dropdown letting the user switch which subnetwork of a chain // is showing on that chain's row. Every network under the chain gets a // menu row with its own summed total, so the user can see all the // balances before flipping. Only one menu can be open at a time. let netMenuEl = null; let netMenuDismiss = null; function closeNetworkPicker() { if (netMenuEl && netMenuEl.parentNode) netMenuEl.parentNode.removeChild(netMenuEl); netMenuEl = null; if (netMenuDismiss) { document.removeEventListener("mousedown", netMenuDismiss, true); document.removeEventListener("keydown", netMenuDismiss, true); netMenuDismiss = null; } } function openNetworkPicker(anchorEl, chain, chainGroup) { closeNetworkPicker(); const nets = orderNetworks(Array.from(chainGroup.byNet.keys())); let active = activeNetworkByChain.get(chain); if (!active || !nets.includes(active)) active = pickDefaultNetwork(nets); const items = nets.map((n) => { const gw = chainGroup.byNet.get(n) || []; const decimals = gw[0]?.decimals || 8; const units = sumGroupUnits(gw); const native = fmtBig(units || 0, decimals); const isTest = n !== "mainnet"; // Mainnet is the chain itself — labelling it "Mainnet" reads as // redundant next to the ticker. Show the plain ticker instead // (e.g. "BCH"), and reserve the specific-network name for the // testnets that need disambiguation (Chipnet / Sepolia / Nile / …). const ticker = gw[0]?.ticker || chain.toUpperCase(); const label = isTest ? networkLabelFor(chain, n, n) : ticker; const cls = isTest ? (chain === "bch" && n === "chipnet" ? "wchipnet" : "wtestnet") : ""; const on = n === active ? "on" : ""; return `
${esc(label)} ${gw.length} ${esc(native)} ${esc(ticker)}
`; }).join(""); netMenuEl = document.createElement("div"); netMenuEl.className = "netmenu"; netMenuEl.innerHTML = items; document.body.appendChild(netMenuEl); const r = anchorEl.getBoundingClientRect(); const mr = netMenuEl.getBoundingClientRect(); const maxLeft = window.innerWidth - mr.width - 8; const left = Math.max(8, Math.min(maxLeft, r.left)); const top = r.bottom + 4; netMenuEl.style.left = left + "px"; netMenuEl.style.top = top + "px"; netMenuEl.querySelectorAll(".nmitem").forEach((it) => it.addEventListener("click", (e) => { e.stopPropagation(); const n = it.dataset.net; closeNetworkPicker(); if (!n || n === active) return; activeNetworkByChain.set(chain, n); renderWalletStrip(); })); netMenuDismiss = (e) => { if (e.type === "keydown" && e.key !== "Escape") return; if (e.type === "mousedown" && netMenuEl && netMenuEl.contains(e.target)) return; closeNetworkPicker(); }; // Defer wiring so the click that opened the menu doesn't immediately close it. setTimeout(() => { document.addEventListener("mousedown", netMenuDismiss, true); document.addEventListener("keydown", netMenuDismiss, true); }, 0); } function wireStripDragDrop(el, chainGroups) { const chainKeys = Array.from(chainGroups.keys()); let dragChain = null; el.querySelectorAll(".wrow[draggable=true]").forEach((row) => { row.addEventListener("dragstart", (e) => { dragChain = row.dataset.chain || null; if (!dragChain) return; row.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragChain); } catch {} }); row.addEventListener("dragend", () => { row.classList.remove("dragging"); el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after")); dragChain = null; }); row.addEventListener("dragover", (e) => { if (!dragChain || row.dataset.chain === dragChain) return; e.preventDefault(); try { e.dataTransfer.dropEffect = "move"; } catch {} const rect = row.getBoundingClientRect(); const before = (e.clientY - rect.top) < rect.height / 2; el.querySelectorAll(".wrow.drop-before, .wrow.drop-after").forEach((r) => r.classList.remove("drop-before", "drop-after")); row.classList.add(before ? "drop-before" : "drop-after"); }); row.addEventListener("dragleave", () => { row.classList.remove("drop-before", "drop-after"); }); row.addEventListener("drop", async (e) => { e.preventDefault(); const targetChain = row.dataset.chain; const before = row.classList.contains("drop-before"); row.classList.remove("drop-before", "drop-after"); if (!dragChain || !targetChain || dragChain === targetChain) return; const next = chainKeys.filter((k) => k !== dragChain); const at = next.indexOf(targetChain); next.splice(before ? at : at + 1, 0, dragChain); // Flatten chain order to a wallet ID list. Inside each chain, // mainnet wallets come first followed by testnets, matching the // dropdown's own order. Individual wallets keep their existing // relative order inside each subnetwork. const walletOrder = []; for (const k of next) { const cg = chainGroups.get(k); if (!cg) continue; const nets = orderNetworks(Array.from(cg.byNet.keys())); for (const n of nets) for (const w of cg.byNet.get(n)) walletOrder.push(w.id); } try { state = await S.invoke("reorderWallets", { order: walletOrder }); render(); } catch (er) { showErr(cleanErr(er)); } }); }); } // Inline replacement for the modal address list. Rendered directly into // the wallet strip element when stripView.mode === "addresses". Header // row has a back arrow (returns to the coins summary) and the coin's // name/logo; body rows show one wallet each with balance + inline ✎ / ⚙. function renderInlineCoinList(el, groupKey, group) { const { meta, wallets: gw } = group; const selId = state?.selectedWalletId; const rows = gw.map((w) => { const on = w.id === selId ? "on" : ""; const units = walletBalanceUnits(w); // Always render a numeric balance — see the same rationale in the // coins summary render. 0 reads as "0", not "—". const bal = fmtBig(units || 0, w.decimals); const usd = usdOf(w.chain, units || 0, w.decimals); const fiat = usd != null ? fmtFiat(usd) : ""; // Address shown as short-head / short-tail, mono. Kept trimmer than // before so the row width holds the balance column comfortably. const addr = w.address ? `${String(w.address).slice(0, 8)}…${String(w.address).slice(-5)}` : ""; // Labels get truncated to ~7 characters here — the full label lives // in the tooltip and stays available via the Rename button. Anything // longer would push the balance column off-screen on tight panels. const shortName = shortLabel(w.label, 7); return `
${logoSvg(meta.logo, 14)} ${esc(shortName)} ${addr ? `${esc(addr)}` : ""} ${esc(bal)} ${fiat ? `${esc(fiat)}` : ""}
`; }).join(""); el.innerHTML = `
${logoSvg(meta.logo, 16)} ${esc(meta.coinName)}· ${gw.length} address${gw.length === 1 ? "" : "es"}
${rows}`; const back = () => { stripView = { mode: "coins", groupKey: null }; renderWalletStrip(); }; el.querySelector("#stripBack").addEventListener("click", back); el.querySelector("#stripBackX").addEventListener("click", back); el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => { if (e.target.closest(".wact")) return; const id = row.dataset.listpick; try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); } catch (er) { showErr(cleanErr(er)); } })); el.querySelectorAll("[data-lpedit]").forEach((b) => b.addEventListener("click", (e) => { e.stopPropagation(); const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit); if (w) openWalletManageModal(w); })); el.querySelectorAll("[data-lpset]").forEach((b) => b.addEventListener("click", async (e) => { e.stopPropagation(); const id = b.dataset.lpset; try { if (id !== state?.selectedWalletId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; } stripView = { mode: "coins", groupKey: null }; showTab("settings"); render(); } catch (er) { showErr(cleanErr(er)); } })); el.querySelector("#stripAddMore").addEventListener("click", async () => { // Add another wallet of the same coin+network directly, without // opening the picker sheet — the user is already inside this coin's // address list so their intent is unambiguous. const first = gw[0]; try { state = await S.invoke("addWallet", { chain: first.chain, network: first.network }); settingsFilled = false; render(); } catch (er) { showErr(cleanErr(er)); } }); } // Small popover for the "⋯" chip on the strip. Lists Import / Connect / // Manage / About without cluttering the strip itself. function openMoreMenu() { const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:flex-start;justify-content:center;z-index:99998;padding-top:60px"; // "Manage current wallet" removed in 0.6.31 — the header's dedicated ✎ // chip already opens the same modal, and the header row itself remains // a click target for the same thing. Two identical entry points were // fine when they were the only path; three would just be clutter. overlay.innerHTML = `

`; document.body.appendChild(overlay); const close = () => { try { overlay.remove(); } catch {} }; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); overlay.querySelectorAll("[data-mm]").forEach((b) => b.addEventListener("click", () => { const action = b.dataset.mm; close(); if (action === "import") openImportModal(null); if (action === "connect") { pickerTab = "connect"; const d=$("drop"); d.hidden=false; fillPicker(); } if (action === "about") openUrl("https://aegis.x/"); })); } function showErr(text) { const box = $("gate"); box.hidden = false; box.innerHTML = `
${esc(text)}
`; setTimeout(() => { if (state?.selected?.phase === "ready") { box.hidden = true; } }, 3500); } // ---- render ---------------------------------------------------------------- // Populate the full-panel lock screen with either a master-password form // (nosetup / no-PIN locked) or a PIN pad (locked with a PIN configured). // PIN mode falls back to master-password via a link at the bottom so a // forgotten PIN never locks the user out of their own vault. function renderLockScreen(phase) { const title = $("lockTitle"); const sub = $("lockSub"); const body = $("lockBody"); if (phase === "nosetup") { title.textContent = "Set up Aegis"; sub.textContent = "Pick a master password — every Aegis wallet is derived from it. The same master password on another machine recreates the same addresses."; body.innerHTML = `
Optional. Paste a mnemonic to derive your vault from an existing seed (Ariadne mobile, another Theseus profile). Leave empty for a fresh independent seed.
`; const doSetup = async () => { const pw = $("gateSetupPw").value; const pw2 = $("gateSetupPw2").value; const mnemonic = $("gateSetupMnemonic").value.trim(); const msg = $("gateSetupMsg"); msg.hidden = true; if (!pw || pw.length < 4) { msg.textContent = "Master password must be 4+ characters."; msg.hidden = false; return; } if (pw !== pw2) { msg.textContent = "Master passwords don't match."; msg.hidden = false; return; } const seedSource = mnemonic ? { kind: "mnemonic", mnemonic } : { kind: "random" }; try { state = await S.invoke("vaultSetup", { masterPassword: pw, seedSource }); render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }; $("gateSetupBtn").addEventListener("click", doSetup); return; } // Locked phase. Two shapes: // 1) PIN configured → 6-digit pad. Falls back to master-password // entry if the user clicks "Use master password". // 2) No PIN → master-password entry directly. const hasPin = !!securityState?.hasPin; const forcePw = body.dataset.forcePw === "1"; title.textContent = "Unlock Aegis"; sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet."; if (hasPin && !forcePw) { body.innerHTML = `
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
Use master password instead · Settings
`; setupPinPad({ dots: $("lockPinDots"), keys: $("lockPinKeys"), err: $("lockPinErr"), onComplete: async (pin) => { const remain = await pinLockoutRemainingMs(); if (remain > 0) { $("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`; return "reset"; } try { const blob = await S.invoke("pinBlobGet"); if (!blob) throw new Error("PIN not set"); const pw = await pinDecryptMaster(pin, blob); state = await S.invoke("vaultUnlock", { masterPassword: pw }); await S.invoke("pinFailReset"); render(); return "ok"; } catch (e) { const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); $("lockPinErr").textContent = left > 0 ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` : `Locked for 15 min — use the master password instead.`; return "reset"; } }, }); $("lockUsePw").addEventListener("click", () => { body.dataset.forcePw = "1"; renderLockScreen("locked"); }); if ($("lockGoSettingsFromPin")) $("lockGoSettingsFromPin").addEventListener("click", () => showTab("settings")); return; } // Master-password entry. body.innerHTML = `
${hasPin ? `Use PIN instead · ` : ""}Settings
`; const doUnlock = async () => { const pw = $("gateUnlockPw").value; const msg = $("gateUnlockMsg"); msg.hidden = true; if (!pw) return; try { state = await S.invoke("vaultUnlock", { masterPassword: pw }); // Remember the master password for a moment so the user can, right // after unlock, enroll a PIN without re-typing it. Cleared as soon // as the panel navigates or reloads. window.__aegisLastPw = pw; setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000); body.dataset.forcePw = ""; render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }; $("gateUnlockBtn").addEventListener("click", doUnlock); $("gateUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); }); try { $("gateUnlockPw").focus(); } catch {} if (hasPin && $("lockUsePin")) $("lockUsePin").addEventListener("click", () => { body.dataset.forcePw = ""; renderLockScreen("locked"); }); if ($("lockGoSettings")) $("lockGoSettings").addEventListener("click", () => showTab("settings")); } // Wire up a PIN pad instance. `onComplete(pin)` runs when 6 digits are // typed and must return "ok" (leave state) or "reset" (clear back to // empty). Rendered by renderLockScreen for the unlock flow and by the // PIN modal helper for set / verify flows. function setupPinPad({ dots, keys, err, onComplete }) { let buf = ""; const paint = () => { const nodes = dots.querySelectorAll(".pindot"); nodes.forEach((n, i) => n.classList.toggle("on", i < buf.length)); }; keys.querySelectorAll("button[data-k]").forEach((b) => b.addEventListener("click", async () => { const k = b.dataset.k; if (err) err.textContent = ""; if (k === "clear") { buf = ""; paint(); return; } if (k === "back") { buf = buf.slice(0, -1); paint(); return; } if (buf.length >= 6) return; buf += k; paint(); if (buf.length === 6) { keys.querySelectorAll("button").forEach((x) => x.disabled = true); let res = "reset"; try { res = await onComplete(buf); } finally { keys.querySelectorAll("button").forEach((x) => x.disabled = false); if (res !== "ok") { buf = ""; paint(); } } } })); // Keyboard fallback — some users prefer typing 6 digits fast. const keyHandler = async (e) => { if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; } if (err) err.textContent = ""; if (/^[0-9]$/.test(e.key)) { if (buf.length >= 6) return; buf += e.key; paint(); if (buf.length === 6) { keys.querySelectorAll("button").forEach((x) => x.disabled = true); let res = "reset"; try { res = await onComplete(buf); } finally { keys.querySelectorAll("button").forEach((x) => x.disabled = false); if (res !== "ok") { buf = ""; paint(); } } } } else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); } else if (e.key === "Escape") { buf = ""; paint(); } }; document.addEventListener("keydown", keyHandler); } function render() { if (!state) return; const s = sel(); const ready = s && s.phase === "ready"; const phase = s?.phase; // Locked / no-setup take over the whole panel — the wallet strip, tab // bar and per-wallet views would show either nothing or partial data, // so we hide them behind an opaque overlay until the vault is open. const fullLock = phase === "locked" || phase === "nosetup"; // Settings is the only always-usable tab (fiat prices, connected sites // — nothing needs a live wallet). Every other tab is gated. const onSettings = tab === "settings"; $("tabs").hidden = !(ready || onSettings); const gate = $("gate"); gate.hidden = ready || onSettings || fullLock; if (onSettings) fillSettings(); const lockScreen = $("lockScreen"); const showLock = fullLock && !onSettings; lockScreen.hidden = !showLock; // Hide the rest of the panel behind the lock overlay. Settings stays // open even while locked (users can set up PIN policy without unlocking // first), so a lock override does NOT hide the tab bar when the user // has clicked into Settings. const hideChrome = fullLock && !onSettings; document.querySelector("header").hidden = hideChrome; document.querySelector("nav").hidden = hideChrome; $("walletStrip").hidden = hideChrome; // Re-drawing the strip after unhiding keeps the coins/addresses view // in sync with the current wallet set. if (!hideChrome) renderWalletStrip(); // Header: replace the badge slot with the coin's SVG and show // $("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22); $("hLabel").textContent = s?.label || "Aegis Wallet"; $("hNet").innerHTML = s?.meta ? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}` : ""; if (showLock) { renderLockScreen(phase); } if (!ready && !fullLock) { const copy = { error: ["⚠", "This wallet could not start.", s?.error || ""], empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."], }[phase] || ["…", "Starting…", ""]; let form = ""; if (phase === "empty") { form = `
`; } gate.innerHTML = `
${copy[0]}
${esc(copy[1])}
${esc(copy[2])}
${form}`; if (phase === "empty") { const btn = $("gateAddWallet"); if (btn) btn.addEventListener("click", () => { const d = $("drop"); pickerTab = "add"; d.hidden = false; fillPicker(); }); } } const dot = $("dot"); dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : ""); $("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || "")); if (ready) { // Sia-specific gate: adapter is up, keys are derived, but no walletd URL // means no balance / history until the user configures one in Settings. if (chain() === "sc" && s.needsWalletdUrl) { $("balMain").textContent = "—"; $("balTicker").textContent = s.meta.ticker; $("netlbl").textContent = "point Aegis at a walletd node in Settings"; $("tabs").hidden = true; gate.hidden = false; gate.innerHTML = `
🗝
Point Aegis at a walletd node
Settings › Sia › walletd URL. Any public or self-hosted go.sia.tech/walletd in "full" index mode works.
`; return; } const total = balanceSum(s.balance); $("balMain").textContent = fmtBig(total); $("balTicker").textContent = s.meta.ticker; const uc = s.balance?.unconfirmed; if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`; // Fiat under the native amount (opt-in, might be null while loading). const usd = usdOf(chain(), total, decimals()); const fiat = fmtFiat(usd) || fiatSkeleton(); $("balFiat").textContent = fiat || ""; $("balFiat").hidden = !fiat; } else { $("balMain").textContent = "—"; $("balTicker").textContent = ""; $("balFiat").hidden = true; } renderPortfolio(); if (!ready) return; const addr = s.address || ""; if ($("addr").textContent !== addr) { $("addr").textContent = addr; drawQr(qrPayload(chain(), addr, sel()?.network)); } $("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : ""; $("nextAddr").hidden = chain() !== "bch"; $("openFaucet").hidden = !s.faucet; // Render SPL tokens list (SOL wallets only). Sending a token clicks // through to the Send tab with that asset pre-picked. renderTokens(); applyUnitPicker(); $("feeField").hidden = chain() !== "bch"; renderHistory(); } // Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N // wallets" under the header. Only rendered when prices are on AND there are // two or more wallets (a single wallet's fiat already sits in #balFiat). function renderPortfolio() { const el = $("portfolio"); const wallets = state?.wallets || []; // Show whenever prices are on and at least one wallet exists — the single- // wallet case still benefits from a portfolio row when the balance-line // fiat is elided (e.g. header hidden during pane switches). if (!state?.prices?.enabled || !wallets.length) { el.hidden = true; return; } let total = 0, priced = 0; for (const w of wallets) { const b = w.balance; if (!b) continue; const units = (typeof b.confirmed === "string") ? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString() : (b.confirmed || 0) + (b.unconfirmed || 0); const usd = usdOf(w.chain, units, w.decimals); if (usd != null) { total += usd; priced++; } } if (!priced) { el.hidden = false; el.innerHTML = `Portfolio: ${esc(fiatSkeleton() || "—")}`; return; } const noun = wallets.length === 1 ? "wallet" : "wallets"; el.hidden = false; el.innerHTML = `Portfolio: ${esc(fmtFiat(total))} across ${wallets.length} ${noun}`; } function renderTokens() { const s = sel(); const tokens = (chain() === "sol" && s?.tokens) || []; const card = $("tokensCard"); card.hidden = tokens.length === 0; if (!tokens.length) return; const el = $("tokensList"); el.innerHTML = tokens.map((t) => { const dec = Number(t.decimals) || 0; const bal = fmtTokenAmount(t.balance, dec); return `
${esc(t.symbol)}${t.name ? ' ' + esc(t.name) + '' : ""}
${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}
${esc(bal)}
`; }).join(""); el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => { sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) }; showTab("send"); })); } // Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. function fmtTokenAmount(rawStr, decimals) { const s = String(rawStr || "0"); const neg = s.startsWith("-"); const abs = neg ? s.slice(1) : s; const d = Number(decimals) || 0; if (d === 0) return (neg ? "-" : "") + abs; const pad = abs.padStart(d + 1, "0"); const whole = pad.slice(0, pad.length - d); const frac = pad.slice(pad.length - d).replace(/0+$/, ""); return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); } function applyUnitPicker() { const s = sel(); if (!s) return; if (!unit) unit = "big"; // ---- SPL asset picker (SOL wallets with tokens) --------------------- const tokens = (chain() === "sol" && s.tokens) || []; const assetField = $("sendAssetField"); if (tokens.length) { assetField.hidden = false; const sel_ = $("sendAsset"); // Rebuild whenever the asset set changes so a new token appears. const key = tokens.map((t) => t.mint).join("|"); if (sel_.dataset.key !== key) { sel_.dataset.key = key; sel_.innerHTML = `` + tokens.map((t) => `` ).join(""); sel_.onchange = () => { const opt = sel_.options[sel_.selectedIndex]; sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null; applyUnitPicker(); schedulePlan(); }; } // Reflect the current sendAsset back into the select. sel_.value = sendAsset ? sendAsset.mint : ""; } else { assetField.hidden = true; sendAsset = null; } const isToken = sendAsset != null; const big = isToken ? sendAsset.symbol : bigUnitLabel(); const small = isToken ? "raw" : smallUnitLabel(); $("unitPicker").innerHTML = `` + ``; $("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u))); $("sendTo").placeholder = ({ bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…", btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)", trx: "T… (base58check, 34 chars)", sc: "addr1… (76-hex + checksum)", dgb: "dgb1q… (or D… / S… depending on family)", eth: "0x… (40 hex chars, EIP-55)", sol: "base58 public key (32 bytes)", })[chain()] || "recipient address"; $("sendAmt").placeholder = unit === "big" ? "0.00" : "0"; } function setUnit(u) { if (u === unit) return; const s = amountUnits(); unit = u; applyUnitPicker(); if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s); updateSendFiatPreview(); } function amountUnits() { const raw = $("sendAmt").value.trim().replace(/,/g, ""); if (!raw) return 0; // For SPL tokens the amount is a raw u64 string in the token's own // smallest unit — same BigInt-safe path SC uses. const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); const bigDecimals = sendAsset != null || d > 15; if (unit === "small") { if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; return Math.round(Number(raw)); } const [w, f = ""] = raw.split("."); const frac = (f + "0".repeat(d)).slice(0, d); if (bigDecimals) { const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0"); return total.toString(); } return Number(w || 0) * Math.pow(10, d) + Number(frac || 0); } // Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere). function balanceSum(b) { if (!b) return 0; if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") { return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString(); } return (b.confirmed || 0) + (b.unconfirmed || 0); } // ---- history --------------------------------------------------------------- function renderHistory() { const s = sel(); const list = s?.history || []; const el = $("txlist"); if (!list.length) { el.innerHTML = `
${s?.scanning ? "Syncing…" : "No transactions yet."}
`; return; } el.innerHTML = list.map((t) => { const inc = t.delta >= 0; const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); const delta = Math.abs(t.delta || 0); return `
${inc ? "↓" : "↑"}
${esc(what)}
${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}
${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}
${esc(conf)}
`; }).join(""); el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid)))); } function shortAddr(a) { if (!a) return ""; const s = String(a).replace(/^bitcoincash:|^bchtest:/, ""); return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4)); } // ---- QR -------------------------------------------------------------------- // Coin-scheme URI so wallet apps that scan know which chain the payment is // for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681 // for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed // URI scheme exists). function qrPayload(chain, address, network) { if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, ""); if (chain === "btc") return "bitcoin:" + address; // BIP21 if (chain === "dgb") return "digibyte:" + address; if (chain === "eth") return "ethereum:" + address; if (chain === "sol") return "solana:" + address; if (chain === "trx") return "tron:" + address; return String(address); } function drawQr(text) { const cv = $("qr"); const g = cv.getContext("2d"); let q; try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; } const scale = Math.max(2, Math.floor(200 / (q.size + 2))); const px = (q.size + 2) * scale; cv.width = cv.height = px; cv.style.width = cv.style.height = px + "px"; g.fillStyle = "#fff"; g.fillRect(0, 0, px, px); g.fillStyle = "#000"; for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale); } // ---- receive actions ------------------------------------------------------- $("copyAddr").addEventListener("click", async () => { try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {} }); $("nextAddr").addEventListener("click", async () => { try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); } catch (e) { flash($("nextAddr"), "Failed"); } }); $("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address))); $("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet)); function flash(btn, text) { const old = btn.textContent; btn.textContent = text; setTimeout(() => { btn.textContent = old; }, 1200); } // ---- send ------------------------------------------------------------------ $("sendMax").addEventListener("click", () => { sendMax = !sendMax; $("sendMax").classList.toggle("primary", sendMax); $("sendAmt").disabled = sendMax; if (!sendMax) $("sendAmt").value = ""; schedulePlan(); }); $("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); }); ["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; if (id === "sendAmt") updateSendFiatPreview(); schedulePlan(); })); // Live ≈$ preview beside the Amount label, updated on every keystroke. Off // when prices are disabled or the input is empty, so a quiet form stays quiet. function updateSendFiatPreview() { const el = $("sendAmtFiat"); if (!el) return; const s = sel(); if (!s || sendAsset) { el.hidden = true; return; } const units = amountUnits(); if (!units || !state?.prices?.enabled) { el.hidden = true; return; } const usd = usdOf(chain(), units, decimals()); const txt = fmtFiat(usd); el.textContent = txt ? "≈ " + txt : ""; el.hidden = !txt; } function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); } async function updatePlan() { const to = $("sendTo").value.trim(); const msg = $("sendMsg"); msg.hidden = true; lastPlan = null; $("sendBtn").disabled = true; $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; $("sendToHint").textContent = ""; if (!to || (!sendMax && !amountUnits())) return; try { if (sendAsset) { // SPL token flow — amount is raw units of the token's decimals. const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() }); lastPlan = { _token: true, ...p }; $("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol; $("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL"; $("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol; $("sendBtn").disabled = false; return; } const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax }); lastPlan = p; $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); $("sumFee").textContent = chain() === "bch" ? fmtSmall(p.fee) + " " + smallUnitLabel() : fmtBig(p.fee) + " " + ticker(); $("sumTotal").textContent = fmtBig(p.total) + " " + ticker(); if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value); $("sendBtn").disabled = false; } catch (e) { msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; } } $("sendBtn").addEventListener("click", async () => { if (!lastPlan) return; const msg = $("sendMsg"); msg.hidden = true; // PIN approval gate: when the user has opted into "Require PIN for // sending", panel-initiated sends must clear a PIN check before the // approval overlay even shows. Cancel if PIN check fails. if (!securityLoaded) await refreshSecurityState(); if (securityState.requirePinForSending && securityState.hasPin) { const ok = await verifyPinInteractively("Confirm this send with your PIN."); if (!ok) { msg.className = "msg err"; msg.textContent = "Cancelled — PIN not confirmed."; msg.hidden = false; return; } } $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; try { const isToken = sendAsset && lastPlan._token; const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; const r = isToken ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax }); msg.className = "msg ok"; msg.innerHTML = `Sent. ${esc(r.txid.slice(0, 16))}…`; msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); msg.hidden = false; $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; lastPlan = null; } catch (e) { const t = cleanErr(e); if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; } $("sendBtn").disabled = !lastPlan; } finally { $("sendBtn").textContent = "Send"; } }); // ---- settings -------------------------------------------------------------- function fillSettings() { // Global settings (fiat prices, connected sites) render even when there // is no active wallet / the vault is locked. renderPricesSetting(); renderSites(); renderGeneralSecurity(); const s = sel(); const chainSetup = !!s && s.phase === "ready"; $("walletManage").hidden = !chainSetup; if (!chainSetup) { $("bchSettings").hidden = true; $("trxSettings").hidden = true; $("scSettings").hidden = true; $("dgbSettings").hidden = true; $("btcSettings").hidden = true; $("ethSettings").hidden = true; $("solSettings").hidden = true; return; } $("bchSettings").hidden = chain() !== "bch"; $("trxSettings").hidden = chain() !== "trx"; $("scSettings").hidden = chain() !== "sc"; $("dgbSettings").hidden = chain() !== "dgb"; $("btcSettings").hidden = chain() !== "btc"; $("ethSettings").hidden = chain() !== "eth"; $("solSettings").hidden = chain() !== "sol"; $("removeBtn").disabled = !!s.isLegacy && s.chain === "bch"; $("removeHint").textContent = (s.isLegacy && s.chain === "bch") ? "The default BCH wallet cannot be removed (it protects legacy funds)." : (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : ""); $("renameLabel").value = s.label || ""; if (chain() === "bch") { if (!settingsFilled) { $("setPath").value = s.accountPath || ""; renderServerCheckboxes(); settingsFilled = true; } $("bchServersRow").hidden = s.network !== "mainnet"; $("serverHint").textContent = s.network !== "mainnet" ? "Chipnet uses bundled defaults in this build." : (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected."); $("purpose").textContent = "silentmode/addons/" + (s.purpose || ""); // WC pairing needs the vault-derived signer path. Imported wallets // don't have one yet (M.1b), so we hide the paste field + surface a // clear explanation in its place — otherwise the user hits an opaque // "wc: wallet not ready" error from the addon. const wcImported = s.kind === "imported"; if ($("wcImportedNotice")) $("wcImportedNotice").hidden = !wcImported; if ($("wcInputs")) $("wcInputs").hidden = wcImported; renderWcSites(); } else if (chain() === "trx") { $("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); } else if (chain() === "sc") { if (!settingsFilled) { $("setWalletdUrl").value = s.walletdUrl || ""; settingsFilled = true; } $("scPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); $("scRecovery").innerHTML = ""; } else if (chain() === "dgb") { if (!settingsFilled) { fillFamilyPicker("Dgb", s); settingsFilled = true; } $("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); $("dgbRecovery").innerHTML = ""; } else if (chain() === "btc") { if (!settingsFilled) { fillFamilyPicker("Btc", s); settingsFilled = true; } $("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); $("btcRecovery").innerHTML = ""; } else if (chain() === "eth") { if (!settingsFilled) { $("setEthRpcUrl").value = s.rpcUrl || ""; settingsFilled = true; } $("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); $("ethRecovery").innerHTML = ""; } else if (chain() === "sol") { if (!settingsFilled) { $("setSolRpcUrl").value = s.rpcUrl || ""; settingsFilled = true; } $("solPurpose").textContent = "silentmode/addons/" + (s.purpose || ""); $("solRecovery").innerHTML = ""; } } // Reflect the current price feed state into the Settings toggle + status // line. Called from fillSettings() and whenever fresh state arrives. // General security card — PIN state + "Require PIN for sending" toggle. // Loads (or refreshes) securityState on demand. Shown even when the vault // is locked so users on the Settings tab can flip the require-pin policy // before unlocking. async function renderGeneralSecurity() { if (!securityLoaded) await refreshSecurityState(); if (!sessionLoaded) await refreshSessionState(); const hasPin = !!securityState.hasPin; const set = $("gsPinSet"), chg = $("gsPinChange"), rm = $("gsPinRemove"); const hint = $("pinStatusHint"); const line = $("gsRequirePinLine"), rp = $("gsRequirePin"); if (set) set.hidden = hasPin; if (chg) chg.hidden = !hasPin; if (rm) rm.hidden = !hasPin; if (hint) hint.textContent = hasPin ? "On — Aegis accepts a 6-digit PIN as an alias for your master password." : "Off — Aegis asks for the master password every time."; if (line) line.hidden = !hasPin; if (rp) rp.checked = !!securityState.requirePinForSending; renderSessionSettings(); } // Session card: reflects lockOnClose + idleMinutes + safeStorage // availability into the toggles. When the OS keystore isn't available // (rare — mainly stripped Linux setups), lock-on-close is forced on and // the toggle is disabled with a clear hint. function renderSessionSettings() { const lc = $("gsLockOnClose"); const im = $("gsIdleMinutes"); const hint = $("gsSessionHint"); if (!lc || !im) return; const canRemember = !!sessionState.safeStorageAvailable; lc.checked = !!sessionState.lockOnClose; lc.disabled = !canRemember; im.value = String(sessionState.idleMinutes || 0); if (hint) { if (!canRemember) { hint.textContent = "OS keystore unavailable on this machine — Aegis can't remember the unlock across restarts. Master-password entry on every launch."; } else if (sessionState.lockOnClose) { hint.textContent = "On — Aegis asks for the master password (or PIN) every time Theseus starts."; } else { hint.textContent = "Off — Aegis stays signed in across Theseus restarts. Master password is stored in the OS keystore under this user only."; } } } // Modal helper that captures a PIN via the same 6-digit pad used on the // lock screen. Returns the entered PIN (string of 6 digits) or null if // the user closes without confirming. `confirm` mode double-prompts and // only resolves when both entries match. function openPinModal({ title, subtitle, mode }) { return new Promise((resolve) => { const first = { pin: null }; const wrap = document.createElement("div"); wrap.className = "pinmodal"; wrap.innerHTML = `

${esc(title)}

${esc(subtitle || "")}
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
`; document.body.appendChild(wrap); const close = (val) => { try { wrap.remove(); } catch {} resolve(val); }; wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); }); wrap.querySelector("#pmCancel").addEventListener("click", () => close(null)); setupPinPad({ dots: $("pmDots"), keys: $("pmKeys"), err: $("pmErr"), onComplete: async (pin) => { if (mode === "confirm" && first.pin == null) { first.pin = pin; $("pmSub").textContent = "Re-enter to confirm"; return "reset"; } if (mode === "confirm" && first.pin !== pin) { $("pmErr").textContent = "PINs don't match. Start again."; first.pin = null; $("pmSub").textContent = subtitle || ""; return "reset"; } close(pin); return "ok"; }, }); }); } $("gsPinSet") && $("gsPinSet").addEventListener("click", async () => { await handlePinSet(); }); $("gsPinChange") && $("gsPinChange").addEventListener("click", async () => { await handlePinSet(true); }); $("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => { if (!confirm("Remove the quick-access PIN? You'll have to type the master password on every unlock again.")) return; try { await S.invoke("pinBlobClear"); // Also disable the send-time PIN policy — it depends on having a PIN. await S.invoke("securitySet", { requirePinForSending: false }); await refreshSecurityState(); renderGeneralSecurity(); } catch (e) { alert("Could not remove PIN: " + cleanErr(e)); } }); $("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => { const on = $("gsRequirePin").checked; try { securityState = await S.invoke("securitySet", { requirePinForSending: on }); renderGeneralSecurity(); } catch (e) { $("gsRequirePin").checked = !on; alert("Could not save setting: " + cleanErr(e)); } }); $("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => { // Route through the addon so it can pass the section slug back to // Theseus (main-process gates section-hint validation). S.invoke("openSettings", { section: "passwords" }).catch(() => {}); }); // Session controls: Lock-on-close toggle + Idle-lock dropdown + Sign out. // Turning "Lock on close" OFF is the "stay signed in" opt-in — we need // the master password once to seed the OS keystore. Turning it back ON // wipes the stored blob and reverts to the classic every-launch prompt. $("gsLockOnClose") && $("gsLockOnClose").addEventListener("change", async () => { const on = $("gsLockOnClose").checked; try { if (!on) { const pw = window.__aegisLastPw || await promptMasterPassword({ title: "Stay signed in", subtitle: "Aegis needs your master password once to encrypt it into the OS keystore. It never touches disk in plaintext.", }); if (!pw) { $("gsLockOnClose").checked = true; return; } sessionState = await S.invoke("sessionEnable", { masterPassword: pw }); // Drop the buffered password immediately — safeStorage now holds it. try { delete window.__aegisLastPw; } catch {} } else { sessionState = await S.invoke("sessionDisable"); } renderSessionSettings(); bindIdleAutoLock(); } catch (e) { $("gsLockOnClose").checked = !on; alert("Could not save setting: " + cleanErr(e)); } }); $("gsIdleMinutes") && $("gsIdleMinutes").addEventListener("change", async () => { const mins = Number($("gsIdleMinutes").value) || 0; try { sessionState = await S.invoke("sessionConfigSet", { idleMinutes: mins }); renderSessionSettings(); bindIdleAutoLock(); } catch (e) { alert("Could not save idle timeout: " + cleanErr(e)); } }); $("gsSignOut") && $("gsSignOut").addEventListener("click", async () => { if (!confirm("Sign out of Aegis? The vault will re-lock and you'll need the master password (or PIN) to open it again.")) return; try { state = await S.invoke("vaultLock"); stripView = { mode: "coins", groupKey: null }; render(); // Session blob was cleared server-side; refresh our cached view. sessionState = await S.invoke("sessionStatus"); renderSessionSettings(); } catch (e) { alert("Could not sign out: " + cleanErr(e)); } }); // Setting or changing a PIN needs the master password to encrypt against. // If the panel has one buffered from a recent unlock (window.__aegisLastPw) // we use it silently; otherwise we ask, verify via a fresh vaultUnlock, and // then proceed with the PIN capture flow. async function handlePinSet(replacing) { let masterPw = window.__aegisLastPw || null; if (!masterPw) { masterPw = await promptMasterPassword({ title: replacing ? "Confirm master password" : "Set up quick-access PIN", subtitle: replacing ? "We need the master password once to re-encrypt the PIN under a fresh key." : "The PIN is an alias for your master password. Enter the master password once to bind them.", }); if (!masterPw) return; } const pin = await openPinModal({ title: replacing ? "Choose a new PIN" : "Choose a PIN", subtitle: "Six digits", mode: "confirm", }); if (!pin) return; try { const blob = await pinEncryptMaster(pin, masterPw); await S.invoke("pinBlobSet", { blob }); await S.invoke("pinFailReset").catch(() => {}); await refreshSecurityState(); renderGeneralSecurity(); } catch (e) { alert("Could not save PIN: " + cleanErr(e)); } finally { // Drop the buffered password sooner rather than later — we only kept // it around to enroll a PIN without a re-prompt. try { delete window.__aegisLastPw; } catch {} } } // Small modal that captures the master password + verifies it via a // vaultUnlock roundtrip. Resolves with the password string on success or // null on cancel / failure. Used both by PIN enrollment (from Settings) // and by the PIN approval gate when the user chose to fall back. function promptMasterPassword({ title, subtitle }) { return new Promise((resolve) => { const wrap = document.createElement("div"); wrap.className = "pinmodal"; wrap.innerHTML = `

${esc(title || "Confirm master password")}

${esc(subtitle || "")}
`; document.body.appendChild(wrap); const done = (v) => { try { wrap.remove(); } catch {} resolve(v); }; wrap.querySelector("#pmpCancel").addEventListener("click", () => done(null)); const submit = async () => { const pw = $("pmpPw").value; const err = $("pmpErr"); err.hidden = true; if (!pw) return; try { // Re-unlock the vault to confirm the password is correct. Idempotent — // if the vault is already open, calling unlock again is a no-op. state = await S.invoke("vaultUnlock", { masterPassword: pw }); done(pw); } catch (e) { err.textContent = cleanErr(e); err.hidden = false; } }; wrap.querySelector("#pmpOk").addEventListener("click", submit); $("pmpPw").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); try { $("pmpPw").focus(); } catch {} }); } // Ask the user to prove they know the PIN. Uses the same lockout counter // as the unlock flow so an attacker can't drain guesses via a spammed // Send button. Returns true on match, false on cancel / lockout / bad PIN. async function verifyPinInteractively(subtitle) { const remain = await pinLockoutRemainingMs(); if (remain > 0) { alert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`); return false; } return new Promise((resolve) => { const wrap = document.createElement("div"); wrap.className = "pinmodal"; wrap.innerHTML = `

Confirm with PIN

${esc(subtitle || "")}
${"".repeat(6)}
${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")}
`; document.body.appendChild(wrap); const done = (v) => { try { wrap.remove(); } catch {} resolve(v); }; wrap.querySelector("#vpCancel").addEventListener("click", () => done(false)); setupPinPad({ dots: $("vpDots"), keys: $("vpKeys"), err: $("vpErr"), onComplete: async (pin) => { try { const blob = await S.invoke("pinBlobGet"); if (!blob) throw new Error("no PIN configured"); await pinDecryptMaster(pin, blob); await S.invoke("pinFailReset").catch(() => {}); done(true); return "ok"; } catch (e) { const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); $("vpErr").textContent = left > 0 ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` : `Locked for 15 min.`; if (left === 0) { done(false); return "ok"; } return "reset"; } }, }); }); } function renderPricesSetting() { const p = state?.prices; const toggle = $("pricesToggle"); if (!toggle) return; toggle.checked = !!p?.enabled; $("refreshPrices").hidden = !p?.enabled; // Populate the oracle dropdown once per state snapshot. Sources include a // label + origin so users see WHERE each request goes before choosing. const src = $("pricesSource"); const sources = Array.isArray(p?.sources) && p.sources.length ? p.sources : []; if (src && sources.length) { const key = sources.map((s) => s.id).join("|"); if (src.dataset.key !== key) { src.dataset.key = key; src.innerHTML = sources.map((s) => ``).join(""); } src.value = p?.source || sources[0].id; const cur = sources.find((s) => s.id === src.value) || sources[0]; const hint = $("pricesSourceHint"); if (hint) hint.textContent = cur?.coversAll ? "Covers every supported coin in a single request." : "Covers a subset of coins (BCH, BTC, ETH, SOL, TRX)."; } const st = $("pricesStatus"); if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; } if (p.loading) { st.textContent = "Fetching…"; return; } if (p.error) { st.textContent = "Error: " + p.error; return; } if (p.fetchedAt) { const secs = Math.round((Date.now() - p.fetchedAt) / 1000); const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`; st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`; return; } st.textContent = "Enabled — first fetch pending."; } async function renderSites() { let perms = {}; try { perms = await S.invoke("permissions"); } catch {} const origins = Object.keys(perms).filter((o) => { const p = perms[o]; return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress)); }); const el = $("sites"); if (!origins.length) { el.innerHTML = `
None yet.
`; return; } el.innerHTML = origins.map((o) => { const p = perms[o]; const what = []; if (p.readAddress) what.push("BCH address"); if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`); if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address"); return `
${esc(o)}
${esc(what.join(" · "))}
`; }).join(""); el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} })); } $("applySettings").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { const path = $("setPath").value.trim(); if (path && path !== (sel().accountPath || "")) { state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path }); } // Server list is now saved on-checkbox-tick via saveServerCheckboxes(), // so Apply doesn't need to re-collect. Still refresh the pane so any // path change reflects immediately. settingsFilled = false; fillSettings(); render(); flash($("applySettings"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); // Preset BCH mainnet Electrum servers users are likely to have heard of. // Kept in sync with chain-bch.js's defaultServers so a fresh install with no // custom pick behaves like this list. Order = suggested-priority. const BCH_KNOWN_SERVERS = [ "wss://bch.imaginary.cash:50004", "wss://cashnode.bch.ninja:50004", "wss://electroncash.dk:50004", "wss://fulcrum.jettscythe.xyz:50004", ]; function renderServerCheckboxes() { const el = $("setServersList"); if (!el) return; // The current list is the union of user-picked + presets; distinguish so we // can render "custom" rows with a remove button while presets stay stable. const current = new Set((state?.bchServers?.list || []).map(String)); const rows = []; for (const url of BCH_KNOWN_SERVERS) { rows.push({ url, checked: current.has(url), custom: false }); } // Any picked URL that isn't in the presets list is treated as user-added. for (const url of current) { if (!BCH_KNOWN_SERVERS.includes(url)) rows.push({ url, checked: true, custom: true }); } el.innerHTML = rows.map((r) => ``).join(""); el.querySelectorAll("input[type=checkbox]").forEach((cb) => cb.addEventListener("change", saveServerCheckboxes)); el.querySelectorAll("[data-remove]").forEach((b) => b.addEventListener("click", async (e) => { e.preventDefault(); const list = collectServerCheckboxes().filter((u) => u !== b.dataset.remove); try { state = await S.invoke("setBchServers", { servers: list }); settingsFilled = false; fillSettings(); render(); } catch (er) { $("settingsMsg").textContent = cleanErr(er); $("settingsMsg").hidden = false; } })); } function collectServerCheckboxes() { const el = $("setServersList"); if (!el) return []; return [...el.querySelectorAll("input[type=checkbox]")] .filter((cb) => cb.checked) .map((cb) => cb.dataset.server); } async function saveServerCheckboxes() { const list = collectServerCheckboxes(); try { state = await S.invoke("setBchServers", { servers: list }); // Don't rebuild the whole settings pane on every checkbox tick — just // refresh the hint line so the "connected to …" text stays current. if (state?.selected?.chain === "bch") { const s = state.selected; $("serverHint").textContent = (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected."); } } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } } $("addCustomServer").addEventListener("click", async () => { const input = $("setServersCustom"); const url = input.value.trim(); if (!/^wss?:\/\/[^/\s]+$/i.test(url)) { $("settingsMsg").textContent = "Server must look like wss://host:port"; $("settingsMsg").hidden = false; return; } const list = [...new Set([...collectServerCheckboxes(), url])]; try { state = await S.invoke("setBchServers", { servers: list }); input.value = ""; settingsFilled = false; fillSettings(); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("resetServers").addEventListener("click", async () => { try { state = await S.invoke("setBchServers", { servers: [] }); settingsFilled = false; fillSettings(); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("renameBtn").addEventListener("click", async () => { const label = $("renameLabel").value.trim(); if (!label) return; try { state = await S.invoke("renameWallet", { id: state.selectedWalletId, label }); render(); flash($("renameBtn"), "Renamed"); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("removeBtn").addEventListener("click", async () => { const s = sel(); if (!s || s.isLegacy) return; if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) return; try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("showXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); } }); $("showXprv").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); } }); function recoveryHtml(r) { let h = `
Account path
${esc(r.accountPath)}
Account xpub
${esc(r.xpub)}
`; if (r.xprv) h += `
Account private key (xprv)
${esc(r.xprv)}
`; return h; } document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => { if (b.dataset.tab !== "settings") { $("recovery").innerHTML = ""; $("scRecovery").innerHTML = ""; $("dgbRecovery").innerHTML = ""; $("btcRecovery").innerHTML = ""; $("ethRecovery").innerHTML = ""; $("solRecovery").innerHTML = ""; } })); // Sia-specific settings. $("applyWalletdUrl").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { state = await S.invoke("setWalletdUrl", { id: state.selectedWalletId, walletdUrl: $("setWalletdUrl").value.trim() }); settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); $("showScSeed").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("scRecovery").innerHTML = `
First address (index 0)
${esc(r.xpub || "")}
` + (r.xprv ? `
Wallet seed (hex)
${esc(r.xprv)}
` : ""); } catch (e) { $("scRecovery").textContent = cleanErr(e); } }); // Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": // the DOM IDs are #setFamily + #setPath. function fillFamilyPicker(prefix, s) { const families = s.meta?.addressFamilies || []; const current = String(s.accountPath || ""); let currentId = families.find((f) => f.defaultAccountPath === current)?.id; if (!currentId) { const m = /^m\/(\d+)'/.exec(current); const purpose = m ? Number(m[1]) : null; currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id; } $(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || ""; $(`set${prefix}Family`).innerHTML = families.map((f) => `` ).join(""); } // Any family select → auto-fill the sibling path input. document.addEventListener("change", (e) => { const t = e.target; if (!t) return; const m = /^set(Dgb|Btc)Family$/.exec(t.id || ""); if (!m) return; const opt = t.options[t.selectedIndex]; if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path; }); $("applyDgbPath").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setDgbPath").value.trim() }); settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); $("applyBtcPath").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() }); settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); $("showBtcXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("btcRecovery").textContent = cleanErr(e); } }); $("showBtcXprv").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("btcRecovery").textContent = cleanErr(e); } }); // ETH / SOL: RPC URL. $("applyEthRpc").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setEthRpcUrl").value.trim() }); settingsFilled = false; fillSettings(); render(); flash($("applyEthRpc"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); $("applySolRpc").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; try { state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setSolRpcUrl").value.trim() }); settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); $("showEthKey").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("ethRecovery").innerHTML = `
Address
${esc(sel().address || "")}
` + `
Public key (uncompressed hex)
${esc(r.xpub || "")}
` + (r.xprv ? `
Private key (hex)
${esc(r.xprv)}
` : ""); } catch (e) { $("ethRecovery").textContent = cleanErr(e); } }); $("showSolKey").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("solRecovery").innerHTML = `
Address (public key, base58)
${esc(r.xpub || "")}
` + (r.xprv ? `
Wallet seed (hex, 32 bytes)
${esc(r.xprv)}
` : ""); } catch (e) { $("solRecovery").textContent = cleanErr(e); } }); $("showDgbXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("dgbRecovery").textContent = cleanErr(e); } }); $("showDgbXprv").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("dgbRecovery").textContent = cleanErr(e); } }); // ---- WizardConnect (BCH only) --------------------------------------------- function renderWcSites() { const el = $("wcSites"); if (!el) return; const walletId = state?.selectedWalletId; const conns = (state?.wc && state.wc[walletId]) || []; if (!conns.length) { el.innerHTML = `
No dapps paired yet.
`; return; } el.innerHTML = conns.map((c) => { const label = c.dappName || "(pairing…)"; const iconHtml = c.dappIcon ? `` : ""; return `
${iconHtml}
${esc(label)}
${esc((c.uri || "").slice(0, 46))}…
`; }).join(""); el.querySelectorAll("button[data-wcconn]").forEach((b) => b.addEventListener("click", async () => { try { state = await S.invoke("wcDisconnect", { walletId, connId: b.dataset.wcconn }); render(); } catch (e) { const m = $("wcMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; } })); } $("wcConnectBtn").addEventListener("click", async () => { const walletId = state?.selectedWalletId; const uri = $("wcUri").value.trim(); const m = $("wcMsg"); m.hidden = true; if (!uri) return; try { state = await S.invoke("wcConnect", { walletId, uri }); $("wcUri").value = ""; m.className = "msg ok"; m.textContent = "Pairing…"; m.hidden = false; render(); } catch (e) { m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; } }); // ---- prices toggle --------------------------------------------------------- $("pricesToggle").addEventListener("change", async () => { const on = $("pricesToggle").checked; try { state = await S.invoke("setPricesEnabled", { enabled: on }); render(); if (tab === "settings") renderPricesSetting(); } catch (e) { // Roll the checkbox back if the host rejected the change. $("pricesToggle").checked = !on; $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("pricesSource").addEventListener("change", async () => { const source = $("pricesSource").value; try { state = await S.invoke("setPricesSource", { source }); renderPricesSetting(); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("refreshPrices").addEventListener("click", async () => { try { await S.invoke("refreshPrices"); // The host emits a state event on completion; the render will pick it up. renderPricesSetting(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); // ---- boot ------------------------------------------------------------------ S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); }); (async () => { // Load security + session state first so the very first render() knows // whether to paint the PIN pad on the lock screen and what idle-lock // timer to arm once the vault is open. try { await refreshSecurityState(); } catch {} try { await refreshSessionState(); } catch {} try { state = await S.invoke("state"); render(); } catch (e) { $("gate").hidden = false; $("gate").innerHTML = `
${esc(cleanErr(e))}
`; } bindIdleAutoLock(); })(); // Persistent footer: aegis.x brand link + version marker + update check. // The check button hits the OTA manifest and compares versions client-side; // when a newer one is advertised, the pill turns into an "Update to vX.Y.Z" // chip. Clicking that chip fires the addon-message "requestUpdate" which // runs the same check + apply flow used by Settings > Extensions > Aegis // (falls back to opening Settings for pre-0.3.47 Theseus that lacks the // panel-facing apply path). const OTA_URL = "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json"; let footerCurrentVer = null; let footerLatestKnown = null; function cmpSemver(a, b) { const pa = String(a || "0").split(".").map((n) => Number(n) || 0); const pb = String(b || "0").split(".").map((n) => Number(n) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } // Track whether the last check was manual. Auto-checks stay silent when // nothing new is available; manual clicks always get a visible reply so // the ↻ button never feels dead when the user is already current. let footerLastCheckManual = false; function paintFooterUpdate() { const el = $("brandUpdate"); if (!el) return; if (!footerCurrentVer || !footerLatestKnown) { el.hidden = true; return; } if (cmpSemver(footerLatestKnown, footerCurrentVer) > 0) { el.hidden = false; el.className = "brandupd"; el.textContent = "↑ Update to v" + footerLatestKnown; el.title = "Aegis v" + footerLatestKnown + " is available — click to apply"; el.style.cursor = "pointer"; el.onclick = () => triggerFooterUpdate(); return; } // At-or-past latest: silent on auto-check (unobtrusive), transient // "up to date" flash on manual so the ↻ click has visible feedback. if (footerLastCheckManual) { el.hidden = false; el.className = "brandupd brandok"; el.textContent = "✓ Up to date"; el.title = "Aegis v" + footerCurrentVer + " is the latest"; el.style.cursor = "default"; el.onclick = null; setTimeout(() => { // Only clear if we're still in the "up to date" state — an update // that arrives during the flash window keeps the newer message. if (el.classList.contains("brandok")) el.hidden = true; }, 2200); } else { el.hidden = true; } } async function checkFooterUpdate(opts = {}) { const btn = $("brandCheck"); if (btn) btn.classList.add("spin"); footerLastCheckManual = !!opts.manual; try { // The addon frame runs under a file:// origin — fetch to https is fine, // no CORS block since no server headers are involved for a same-origin // request... actually addon frames CAN cross-fetch. Cache-bust with a // per-minute query so a fresh check reflects a just-published manifest. const bust = Math.floor(Date.now() / 60_000); const r = await fetch(OTA_URL + "?t=" + bust, { cache: "no-store" }); if (!r.ok) throw new Error("HTTP " + r.status); const j = await r.json(); const entries = Array.isArray(j?.addons) ? j.addons : []; let best = null; for (const e of entries) if (!best || cmpSemver(e.version, best.version) > 0) best = e; footerLatestKnown = best?.version || null; paintFooterUpdate(); } catch (e) { console.warn("footer update check failed:", e?.message || e); if (footerLastCheckManual) { const el = $("brandUpdate"); if (el) { el.hidden = false; el.className = "brandupd branderr"; el.textContent = "⚠ Check failed"; el.title = String(e?.message || e); el.style.cursor = "default"; el.onclick = null; setTimeout(() => { if (el.classList.contains("branderr")) el.hidden = true; }, 2500); } } } finally { if (btn) btn.classList.remove("spin"); } } // Two-step chip flow. First click → stage the newer signed build; the // chip's message and click handler swap to "Restart Theseus to apply". // Second click → app.relaunch(). Both steps go through the same // requestUpdate handler so a single Theseus IPC round-trip covers each // leg. Falls back to opening Settings › Extensions when running under // an older Theseus that lacks the panel-driven update hooks. async function triggerFooterUpdate() { const el = $("brandUpdate"); if (!el) return; const setChip = (text, klass, title, handler) => { el.hidden = false; el.className = "brandupd" + (klass ? " " + klass : ""); el.textContent = text; el.title = title || ""; el.style.cursor = handler ? "pointer" : "default"; el.onclick = handler || null; }; try { setChip("Staging update…", "brandwait", "Downloading + verifying the signed payload", null); const r = await S.invoke("requestUpdate", { step: "stage" }); if (r?.fallback === "settings") { setChip("Open Settings to update", null, "This Theseus lacks the in-panel updater — opening Settings › Extensions", () => S.invoke("openSettings", { section: "addons" }).catch(() => {})); return; } if (r?.staged) { const nextVer = r.next ? " v" + r.next : ""; setChip("↻ Restart to apply" + nextVer, null, "Aegis" + nextVer + " is staged — click to relaunch Theseus", async () => { setChip("Restarting…", "brandwait", "", null); try { await S.invoke("requestUpdate", { step: "apply" }); } catch (e) { setChip("⚠ Restart failed", "branderr", String(e?.message || e), null); } }); return; } // Server responded but nothing to stage — surface the reason briefly. const msg = r?.status === "up-to-date" ? "✓ Already up to date" : r?.status ? "⚠ " + r.status : "⚠ Update failed"; setChip(msg, r?.status === "up-to-date" ? "brandok" : "branderr", r?.detail || "", null); setTimeout(() => { if (el.classList.contains("brandok") || el.classList.contains("branderr")) el.hidden = true; }, 2500); } catch (e) { console.warn("update trigger failed:", e?.message || e); setChip("⚠ Update failed", "branderr", String(e?.message || e), null); setTimeout(() => { if (el.classList.contains("branderr")) el.hidden = true; }, 2500); } } (function wireFooter() { const link = $("brandLink"); if (!link) return; link.addEventListener("click", (e) => { e.preventDefault(); openUrl("https://aegis.x/"); }); // Version comes from the addon manifest; if the state message carries it // we surface it, otherwise the slot stays empty. S.invoke("aegisVersion").then((v) => { const el = $("brandVer"); if (el && v) el.textContent = "v" + String(v); footerCurrentVer = v || null; paintFooterUpdate(); }).catch(() => {}); const check = $("brandCheck"); if (check) check.addEventListener("click", () => checkFooterUpdate({ manual: true })); // First check on panel open — non-blocking; failures stay quiet. A user // who never opens Settings still gets a clear update signal here. setTimeout(() => checkFooterUpdate({ manual: false }), 500); })();