The transaction lists and ENS name lookups on the address and token screens, the address scan after a wallet is created, the endpoint checks in Settings, the wait screen's receipt check, the Send screen's Max fee estimate and the token lookup on both add-token screens now check the signal the popup aborts on pagehide before reporting a failed request. scanForAddresses(), resolveEnsNames() and lookupTokenInfo() take the signal. End-to-end tests reload the popup on the address screen and during the address scan with their requests held. Jest tests show each of these reports a real failure and stays silent once the popup has closed. The transaction detail and confirmation screens are left out: they discard the popup context that carries the signal. Model: opus-5-5
457 lines
17 KiB
JavaScript
457 lines
17 KiB
JavaScript
const {
|
||
$,
|
||
showView,
|
||
updateDebugBanner,
|
||
showFlash,
|
||
escapeHtml,
|
||
displaySymbol,
|
||
flashCopyFeedback,
|
||
goBack,
|
||
pushCurrentView,
|
||
} = require("./helpers");
|
||
const { applyTheme } = require("../theme");
|
||
const {
|
||
DUST_THRESHOLD_MESSAGE,
|
||
parseDustThresholdGwei,
|
||
} = require("../dustThreshold");
|
||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||
const {
|
||
log,
|
||
debugFetch,
|
||
urlOrigin,
|
||
setRuntimeDebug,
|
||
} = require("../../shared/log");
|
||
const deleteWallet = require("./deleteWallet");
|
||
const showPhrase = require("./showPhrase");
|
||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||
const {
|
||
BUILD_VERSION,
|
||
BUILD_LICENSE,
|
||
BUILD_AUTHOR,
|
||
BUILD_COMMIT,
|
||
BUILD_DATE,
|
||
GITEA_COMMIT_URL,
|
||
} = require("../../shared/buildInfo");
|
||
|
||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||
|
||
let versionClickCount = 0;
|
||
let versionClickTimer = null;
|
||
|
||
// One row per site origin, however many addresses it appears under, each with
|
||
// an [x] that hands it to onRemove.
|
||
function renderSiteList(containerId, origins, onRemove) {
|
||
const container = $(containerId);
|
||
const unique = [...new Set(origins)];
|
||
if (unique.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
unique.forEach((origin) => {
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
// An origin the URL parser produced cannot carry a delimiter, so
|
||
// this is escaped for the rule rather than for a known hole — the
|
||
// rule being that nothing reaches innerHTML unescaped.
|
||
html += `<span>${escapeHtml(origin)}</span>`;
|
||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
||
});
|
||
}
|
||
|
||
// Drop a site origin from a remembered site list under every address.
|
||
function forgetOrigin(siteMap, origin) {
|
||
for (const addr of Object.keys(siteMap)) {
|
||
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
|
||
if (siteMap[addr].length === 0) {
|
||
delete siteMap[addr];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||
// no longer allowed under any address, and the background ends its
|
||
// connections approved without "Remember" and tells its open tabs.
|
||
async function removeAllowedSite(origin) {
|
||
forgetOrigin(state.allowedSites, origin);
|
||
await saveState();
|
||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
||
await renderSiteLists();
|
||
}
|
||
|
||
// Removing a denied site only forgets the refusal; it connects nothing.
|
||
async function removeDeniedSite(origin) {
|
||
forgetOrigin(state.deniedSites, origin);
|
||
await saveState();
|
||
await renderSiteLists();
|
||
}
|
||
|
||
function renderTrackedTokens() {
|
||
const container = $("settings-tracked-tokens");
|
||
if (state.trackedTokens.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
state.trackedTokens.forEach((token, idx) => {
|
||
const sym = escapeHtml(displaySymbol(token.symbol));
|
||
const label = token.name
|
||
? escapeHtml(token.name) + " (" + sym + ")"
|
||
: sym;
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
html += `<span>${label}</span>`;
|
||
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-remove-token").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
state.trackedTokens.splice(idx, 1);
|
||
await saveState();
|
||
renderTrackedTokens();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderWalletListSettings() {
|
||
const container = $("settings-wallet-list");
|
||
if (state.wallets.length === 0) {
|
||
container.innerHTML = '<p class="text-xs text-muted">No wallets.</p>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
state.wallets.forEach((wallet, idx) => {
|
||
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
|
||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
|
||
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
|
||
// Key and xprv wallets have no recovery phrase, so they are never
|
||
// offered the action at all.
|
||
if (walletHasRecoveryPhrase(wallet)) {
|
||
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
|
||
}
|
||
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
|
||
html += `</span>`;
|
||
html += `</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
container.querySelectorAll(".btn-delete-wallet").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
pushCurrentView();
|
||
deleteWallet.show(idx);
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const idx = parseInt(btn.dataset.idx, 10);
|
||
// No pushCurrentView() here: showPhrase.show() refuses
|
||
// non-HD wallets and pushes only when it navigates.
|
||
showPhrase.show(idx);
|
||
});
|
||
});
|
||
|
||
// Inline rename on click
|
||
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
|
||
span.addEventListener("click", () => {
|
||
const idx = parseInt(span.dataset.idx, 10);
|
||
const wallet = state.wallets[idx];
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.className =
|
||
"border border-border p-0 text-xs bg-bg text-fg w-full";
|
||
input.value = wallet.name || "Wallet " + (idx + 1);
|
||
span.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
const finish = async () => {
|
||
const val = input.value.trim();
|
||
if (val && val !== wallet.name) {
|
||
wallet.name = val;
|
||
await saveState();
|
||
}
|
||
renderWalletListSettings();
|
||
};
|
||
input.addEventListener("blur", finish);
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") input.blur();
|
||
if (e.key === "Escape") {
|
||
input.value = wallet.name || "Wallet " + (idx + 1);
|
||
input.blur();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function show() {
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
$("settings-network").value = state.networkId;
|
||
renderTrackedTokens();
|
||
renderSiteLists();
|
||
renderWalletListSettings();
|
||
|
||
// Populate About well
|
||
$("about-license").textContent = BUILD_LICENSE;
|
||
// Show only the name part of the author field (strip email)
|
||
const authorName = BUILD_AUTHOR.replace(/\s*<[^>]+>/, "");
|
||
$("about-author").textContent = authorName;
|
||
$("about-version").textContent = BUILD_VERSION;
|
||
$("about-release-date").textContent = BUILD_DATE;
|
||
$("about-commit-link").textContent = BUILD_COMMIT;
|
||
$("about-commit-link").href = GITEA_COMMIT_URL;
|
||
|
||
// Reset version click counter each time settings opens
|
||
versionClickCount = 0;
|
||
|
||
// Show debug well if debug mode is already enabled
|
||
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
||
$("settings-debug-mode").checked = state.debugMode;
|
||
|
||
showView("settings");
|
||
}
|
||
|
||
async function renderSiteLists() {
|
||
renderSiteList(
|
||
"settings-allowed-sites",
|
||
Object.values(state.allowedSites).flat(),
|
||
removeAllowedSite,
|
||
);
|
||
renderSiteList(
|
||
"settings-denied-sites",
|
||
Object.values(state.deniedSites).flat(),
|
||
removeDeniedSite,
|
||
);
|
||
// Sites allowed without "Remember" are held only by the background, in
|
||
// memory, so it is asked for them.
|
||
renderSiteList(
|
||
"settings-connected-sites",
|
||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
||
removeAllowedSite,
|
||
);
|
||
}
|
||
|
||
function init(ctx) {
|
||
deleteWallet.init(ctx);
|
||
showPhrase.init();
|
||
|
||
$("btn-save-rpc").addEventListener("click", async () => {
|
||
const url = $("settings-rpc").value.trim();
|
||
if (!url) {
|
||
showFlash("Please enter an RPC URL.");
|
||
return;
|
||
}
|
||
showFlash("Testing endpoint...");
|
||
try {
|
||
const resp = await debugFetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
jsonrpc: "2.0",
|
||
id: 1,
|
||
method: "eth_chainId",
|
||
params: [],
|
||
}),
|
||
});
|
||
const json = await resp.json();
|
||
if (json.error) {
|
||
log.errorf("RPC validation error:", json.error);
|
||
showFlash("Endpoint returned an error.");
|
||
return;
|
||
}
|
||
const net = currentNetwork();
|
||
if (json.result !== net.chainId) {
|
||
showFlash("Wrong network: expected " + net.name + ".");
|
||
return;
|
||
}
|
||
} catch {
|
||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||
// src/popup/index.js.
|
||
if (ctx.pageClosed.aborted) return;
|
||
// Not the error's message: fetch puts the whole URL, password and
|
||
// key included, in the message of the error it throws for a URL
|
||
// with a user name and password or one it cannot parse.
|
||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
||
showFlash("Could not reach endpoint.");
|
||
return;
|
||
}
|
||
state.rpcUrl = url;
|
||
await saveState();
|
||
showFlash("Saved.");
|
||
});
|
||
|
||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||
const url = $("settings-blockscout").value.trim();
|
||
if (!url) {
|
||
showFlash("Please enter a Blockscout API URL.");
|
||
return;
|
||
}
|
||
showFlash("Testing endpoint...");
|
||
try {
|
||
const resp = await debugFetch(url + "/stats");
|
||
if (!resp.ok) {
|
||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||
return;
|
||
}
|
||
} catch {
|
||
if (ctx.pageClosed.aborted) return;
|
||
// Not the error's message, as for the RPC check above.
|
||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||
showFlash("Could not reach endpoint.");
|
||
return;
|
||
}
|
||
state.blockscoutUrl = url;
|
||
await saveState();
|
||
showFlash("Saved.");
|
||
});
|
||
|
||
const networkSelect = $("settings-network");
|
||
networkSelect.addEventListener("change", async () => {
|
||
const newId = networkSelect.value;
|
||
const net = await onChainSwitch(newId);
|
||
$("settings-rpc").value = state.rpcUrl;
|
||
$("settings-blockscout").value = state.blockscoutUrl;
|
||
showFlash("Switched to " + net.name + ".");
|
||
});
|
||
|
||
$("settings-show-zero-balances").checked = state.showZeroBalanceTokens;
|
||
$("settings-show-zero-balances").addEventListener("change", async () => {
|
||
state.showZeroBalanceTokens = $("settings-show-zero-balances").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-theme").value = state.theme;
|
||
$("settings-theme").addEventListener("change", async () => {
|
||
state.theme = $("settings-theme").value;
|
||
await saveState();
|
||
applyTheme(state.theme);
|
||
});
|
||
|
||
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
|
||
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
|
||
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
|
||
$("settings-hide-low-holders").addEventListener("change", async () => {
|
||
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-fraud-contracts").checked = state.hideFraudContracts;
|
||
$("settings-hide-fraud-contracts").addEventListener("change", async () => {
|
||
state.hideFraudContracts = $("settings-hide-fraud-contracts").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-hide-dust").checked = state.hideDustTransactions;
|
||
$("settings-hide-dust").addEventListener("change", async () => {
|
||
state.hideDustTransactions = $("settings-hide-dust").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||
$("settings-dust-threshold").addEventListener("change", async () => {
|
||
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
|
||
// Rejected input is never coerced. The field is put back to the
|
||
// stored threshold so it never shows a value the wallet is not
|
||
// using, and the message says what the field wants so the snap-back
|
||
// is explained rather than silent.
|
||
if (val === null) {
|
||
showFlash(DUST_THRESHOLD_MESSAGE);
|
||
} else {
|
||
state.dustThresholdGwei = val;
|
||
await saveState();
|
||
}
|
||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||
});
|
||
|
||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||
$("settings-utc-timestamps").addEventListener("change", async () => {
|
||
state.utcTimestamps = $("settings-utc-timestamps").checked;
|
||
await saveState();
|
||
});
|
||
|
||
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
||
|
||
$("btn-settings-add-token").addEventListener(
|
||
"click",
|
||
ctx.showSettingsAddTokenView,
|
||
);
|
||
|
||
// Bright saturated colors for easter egg flashes (clicks 6–10)
|
||
const easterEggColors = [
|
||
"#ff0055", // hot pink
|
||
"#00cc44", // vivid green
|
||
"#3366ff", // electric blue
|
||
"#ff9900", // bright orange
|
||
"#aa00ff", // vivid purple
|
||
];
|
||
|
||
// Easter egg: click version 10 times to reveal the debug well.
|
||
// Each click does a copy-flash animation. After 5 clicks, each
|
||
// additional click flashes a different bright saturated color.
|
||
$("about-version").addEventListener("click", () => {
|
||
versionClickCount++;
|
||
clearTimeout(versionClickTimer);
|
||
// Reset counter if user stops clicking for 3 seconds
|
||
versionClickTimer = setTimeout(() => {
|
||
versionClickCount = 0;
|
||
}, 3000);
|
||
|
||
const el = $("about-version");
|
||
|
||
if (versionClickCount > 5) {
|
||
// Colored flash for clicks 6–10
|
||
const colorIdx = versionClickCount - 6;
|
||
const color = easterEggColors[colorIdx % easterEggColors.length];
|
||
el.classList.remove("copy-flash-fade");
|
||
el.style.backgroundColor = color;
|
||
el.style.color = "#ffffff";
|
||
setTimeout(() => {
|
||
el.style.backgroundColor = "";
|
||
el.style.color = "";
|
||
el.classList.add("copy-flash-fade");
|
||
setTimeout(() => {
|
||
el.classList.remove("copy-flash-fade");
|
||
}, 275);
|
||
}, 75);
|
||
} else {
|
||
// Standard copy-flash for clicks 1–5
|
||
flashCopyFeedback(el);
|
||
}
|
||
|
||
if (versionClickCount >= 10) {
|
||
versionClickCount = 0;
|
||
clearTimeout(versionClickTimer);
|
||
$("settings-debug-well").classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
// Debug mode toggle — update runtime flag, persist, and re-render banner
|
||
$("settings-debug-mode").addEventListener("change", async () => {
|
||
state.debugMode = $("settings-debug-mode").checked;
|
||
setRuntimeDebug(state.debugMode);
|
||
await saveState();
|
||
updateDebugBanner(state.currentView);
|
||
});
|
||
|
||
// Sync runtime debug flag on init
|
||
setRuntimeDebug(state.debugMode);
|
||
|
||
$("btn-settings-back").addEventListener("click", () => {
|
||
goBack();
|
||
});
|
||
}
|
||
|
||
module.exports = { init, show, renderSiteLists };
|