// Shared DOM helpers used by all views. // // Escaping rule for every view in this directory, since they all build // markup by concatenation: any VALUE interpolated into an innerHTML string // goes through escapeHtml(), whatever its provenance looks like today. The // only interpolations left bare are markup FRAGMENTS this code just built // (a rendered dot, an icon, a composed row), which escaping would turn into // visible angle brackets, and locally computed numbers and loop indices. // The distinction is meant to be greppable: an unescaped `${` next to a // name that reads like data is a defect. // escapeHtml lives in src/shared/html.js, where the escape and the // reasoning behind it are; it is re-exported below so views keep importing // it from here. const { escapeHtml } = require("../../shared/html"); const { isDebug } = require("../../shared/log"); const { formatUsd, getPrice } = require("../../shared/prices"); const { state, saveState, currentNetwork } = require("../../shared/state"); const { displaySymbol } = require("../../shared/symbolDisplay"); const { markViewRendered } = require("../viewRouter"); // When views are added, removed, or transitions between them change, // update the view-navigation documentation in README.md to match. const VIEWS = [ "welcome", "add-wallet", "main", "address", "address-token", "send", "confirm-tx", "wait-tx", "success-tx", "error-tx", "receive", "add-token", "settings", "delete-wallet-confirm", "delete-wallet-lost-password", "delete-address-confirm", "settings-addtoken", "transaction", "approve-site", "approve-tx", "approve-sign", "export-privkey", "show-phrase", // Shown by src/popup/views/stateRecovery.js when the stored profile // cannot be read. It is never reached through showView() — by then the // state singleton this file writes on every navigation refuses to be read // — but it is listed so that every view-hiding loop covers it. "state-recovery", ]; // Cleanup callbacks for views that hold a secret in the DOM. The view // registers one for itself and showView() runs it whenever that view is // navigated away from, so the secret is wiped no matter which control // caused the navigation — "Back", the settings gear, or a jump from // anywhere else. A per-button clear would only cover the one path. const viewLeaveHandlers = new Map(); function onViewLeave(name, fn) { viewLeaveHandlers.set(name, fn); } function $(id) { return document.getElementById(id); } function showError(id, msg) { const el = $(id); el.textContent = msg; el.style.visibility = "visible"; } function hideError(id) { const el = $(id); el.textContent = ""; el.style.visibility = "hidden"; } function showView(name) { const leaving = state.currentView; if (leaving && leaving !== name) { const onLeave = viewLeaveHandlers.get(leaving); if (onLeave) onLeave(); } for (const v of VIEWS) { const el = document.getElementById(`view-${v}`); if (el) { el.classList.toggle("hidden", v !== name); } } clearFlash(); state.currentView = name; // A view's show() ends here, so this is where the Back path learns the // view is no longer the blank template from index.html and must not be // rendered a second time. See viewRouter.js. markViewRendered(name); saveState(); updateDebugBanner(name); } // Create or update the debug/insecure warning banner. // Called on every view switch and after the settings debug toggle changes. // The banner is shown when the compile-time DEBUG constant is true OR when // the user has enabled runtime debug mode via the settings easter egg, OR // when the active network is a testnet. function updateDebugBanner(viewName) { const debug = isDebug(); const net = currentNetwork(); const show = debug || net.isTestnet; let banner = document.getElementById("debug-banner"); if (show) { if (!banner) { banner = document.createElement("div"); banner.id = "debug-banner"; banner.style.cssText = "background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;"; document.body.prepend(banner); } const suffix = viewName ? " (" + viewName + ")" : ""; if (debug && net.isTestnet) { banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix; } else if (net.isTestnet) { banner.textContent = "[TESTNET]" + suffix; } else { banner.textContent = "DEBUG / INSECURE" + suffix; } } else if (banner) { banner.remove(); } } // The banner shown when a save has failed, registered as the save-failure // reporter by src/popup/index.js. // // Persistent and not dismissable, unlike showFlash(): what it says is true // until the popup is closed, and a message that clears itself after two seconds // is how the user goes on operating a wallet that is persisting nothing // (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation // because it hangs off document.body rather than off a view. // // Created on demand rather than authored in index.html, the same way // updateDebugBanner() creates its own: it is absent from a popup where nothing // has failed, which is the state that must not need markup to be in. // // textContent, never innerHTML: `detail` carries an error message, which may // come from the browser's storage layer. function showSaveFailureBanner(detail) { let banner = document.getElementById("save-failure-banner"); if (!banner) { banner = document.createElement("div"); banner.id = "save-failure-banner"; banner.style.cssText = "background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;"; document.body.prepend(banner); } const message = (detail && (detail.message || detail.problem)) || detail; banner.textContent = "NOT SAVED — AutistMask could not write to storage, so recent" + " changes are not stored. Close and reopen the popup; if this keeps" + " happening, do not rely on anything you change now." + (message ? " (" + String(message) + ")" : ""); } // Callback that renders a view being navigated BACK onto. Set once by // index.js via setBackRenderer(), which routes the view through the same // per-view render and data guards restoreView() uses. // // It answers true when it took the navigation — the view is rendered and // shown, or its backing data was gone and it fell back — and false for a // view the popup does not render from persisted state. Those can only be // on the stack from this page load, because the stack is filtered on load, // so they have already been rendered and only need unhiding. let _renderBack = null; function setBackRenderer(fn) { _renderBack = fn; } // Push the current view onto the navigation stack so goBack() can // return to it. Call this before any forward navigation. function pushCurrentView() { if (state.currentView) { state.viewStack.push(state.currentView); } } // Pop the navigation stack and show the previous view. If the stack // is empty, fall back to the main (home) view. function goBack() { let target; if (state.viewStack.length > 0) { target = state.viewStack.pop(); } else { target = "main"; } // A popped view is landed on, not navigated to. If the popup has been // closed and reopened since the view was pushed, nothing has ever // rendered it in this page load and its template is still blank, so it // has to be rendered here rather than merely unhidden. if (_renderBack && _renderBack(target)) return; showView(target); } // Clear the entire navigation stack (used when resetting to root, // e.g. after adding or deleting a wallet). function clearViewStack() { state.viewStack = []; } let flashTimer = null; function clearFlash() { if (flashTimer) { clearTimeout(flashTimer); flashTimer = null; } $("flash-msg").textContent = ""; } function showFlash(msg, duration = 2000) { clearFlash(); $("flash-msg").textContent = msg; flashTimer = setTimeout(() => { $("flash-msg").textContent = ""; flashTimer = null; }, duration); } // A stored token balance as a number, or null when there is no number in it. // balances.js writes null for a holding whose scale nothing knows, and this // keeps that null from becoming a zero one dereference later. function unknownableAmount(balance) { if (balance == null) return null; const n = parseFloat(balance); return Number.isFinite(n) ? n : null; } // One row of the balance list: symbol, quantity, fiat value. // // `symbol` is the ERC-20's own symbol() as the block explorer reported it, // so it is attacker-chosen markup until it has been through escapeHtml, and // attacker-chosen length until it has been through displaySymbol. This is // the row that issue #307 was reported against: every screen that lists a // holding renders through here. // // `amount` is null for a holding whose scale nothing knows // (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to // print for it and no fiat value to derive from one, and printing 0.0000 for // a real holding is the failure this whole rule exists to prevent, so the row // says so instead. function balanceLine(symbol, amount, price, tokenId) { const qty = amount === null ? "quantity unknown" : amount.toFixed(4); const usd = price && amount !== null ? formatUsd(amount * price) || " " : " "; // tokenId is a contract address out of the same explorer JSON, and it // lands inside a quoted attribute. const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : ""; const clickClass = tokenId ? " cursor-pointer hover:bg-hover balance-row" : ""; return ( `