Split popup into one file per view
All checks were successful
check / check (push) Successful in 4s

popup/index.js reduced to ~75 lines: loads state, builds a
shared context object, initializes all views, shows first screen.

Each view in popup/views/:
  helpers.js      — $(), showError, hideError, showView
  welcome.js      — welcome screen
  addWallet.js    — unified create/import recovery phrase
  importKey.js    — private key import
  home.js         — wallet list, total value, address derivation
  addressDetail.js — address view, token list, QR, copy
  send.js         — send form, ENS resolution, tx broadcast
  receive.js      — QR + copy
  addToken.js     — token lookup, common token picker
  settings.js     — RPC endpoint
  approval.js     — dApp approval (stub)

Views communicate via a ctx object with shared callbacks
(renderWalletList, showAddressDetail, doRefreshAndRender, etc).
This commit is contained in:
2026-02-25 18:51:41 +07:00
parent f50a2a0389
commit 023d8441bc
12 changed files with 673 additions and 581 deletions

View File

@@ -1,237 +1,25 @@
// AutistMask popup UI — view switching and event wiring only.
// All business logic lives in src/shared/*.
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { parseEther } = require("ethers");
const QRCode = require("qrcode");
const { TOKENS } = require("../shared/tokens");
const {
state,
saveState,
loadState,
currentAddress,
} = require("../shared/state");
const {
DEBUG,
generateMnemonic,
deriveAddressFromXpub,
hdWalletFromMnemonic,
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,
} = require("../shared/wallet");
const {
refreshPrices,
formatUsd,
getAddressValueUsd,
getTotalValueUsd,
} = require("../shared/prices");
const {
refreshBalances,
lookupTokenInfo,
invalidateBalanceCache,
getProvider,
} = require("../shared/balances");
const { encryptWithPassword, decryptWithPassword } = require("../shared/vault");
const { DEBUG } = require("../shared/wallet");
const { state, saveState, loadState } = require("../shared/state");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { showView } = require("./views/helpers");
const VIEWS = [
"welcome",
"add-wallet",
"import-key",
"main",
"address",
"send",
"receive",
"add-token",
"settings",
"approve",
];
// -- DOM helpers --
function $(id) {
return document.getElementById(id);
}
function showError(id, msg) {
const el = $(id);
el.textContent = msg;
el.classList.remove("hidden");
}
function hideError(id) {
$(id).classList.add("hidden");
}
function showView(name) {
for (const v of VIEWS) {
const el = document.getElementById(`view-${v}`);
if (el) {
el.classList.toggle("hidden", v !== name);
}
}
if (DEBUG) {
const banner = document.getElementById("debug-banner");
if (banner) {
banner.textContent = "DEBUG / INSECURE (" + name + ")";
}
}
}
// -- rendering --
function renderTotalValue() {
const el = $("total-value");
if (!el) return;
el.textContent = formatUsd(getTotalValueUsd(state.wallets));
}
function renderTokenList(addr) {
const list = $("token-list");
const balances = addr.tokenBalances || [];
if (balances.length === 0 && state.trackedTokens.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No tokens added yet. Use "+ Add" to track a token.</div>';
return;
}
list.innerHTML = balances
.map(
(t) =>
`<div class="py-1 border-b border-border-light flex justify-between">` +
`<span>${t.symbol}</span>` +
`<span>${t.balance || "0"}</span>` +
`</div>`,
)
.join("");
}
function renderSendTokenSelect(addr) {
const sel = $("send-token");
sel.innerHTML = '<option value="ETH">ETH</option>';
for (const t of addr.tokenBalances || []) {
const opt = document.createElement("option");
opt.value = t.address;
opt.textContent = t.symbol;
sel.appendChild(opt);
}
}
function showAddressDetail() {
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
$("address-title").textContent = wallet.name;
$("address-full").textContent = addr.address;
$("address-copied-msg").textContent = "";
$("address-eth-balance").textContent = addr.balance;
$("address-usd-value").textContent = formatUsd(getAddressValueUsd(addr));
const ensEl = $("address-ens");
if (addr.ensName) {
ensEl.textContent = addr.ensName;
ensEl.classList.remove("hidden");
} else {
ensEl.classList.add("hidden");
}
renderTokenList(addr);
renderSendTokenSelect(addr);
showView("address");
}
const home = require("./views/home");
const welcome = require("./views/welcome");
const addWallet = require("./views/addWallet");
const importKey = require("./views/importKey");
const addressDetail = require("./views/addressDetail");
const send = require("./views/send");
const receive = require("./views/receive");
const addToken = require("./views/addToken");
const settings = require("./views/settings");
const approval = require("./views/approval");
function renderWalletList() {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
return;
}
let html = "";
state.wallets.forEach((wallet, wi) => {
html += `<div class="mb-3">`;
html += `<div class="flex justify-between items-center border-b border-border pb-1 mb-1">`;
html += `<span class="font-bold">${wallet.name}</span>`;
if (wallet.type === "hd") {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover px-1" data-wallet="${wi}" data-address="${ai}">`;
if (addr.ensName) {
html += `<div class="text-xs font-bold">${addr.ensName}</div>`;
}
html += `<div class="text-xs break-all">${addr.address}</div>`;
html += `<div class="flex justify-between items-center">`;
html += `<span class="text-xs">${addr.balance} ETH</span>`;
html += `<span class="text-xs text-muted">${formatUsd(getAddressValueUsd(addr))}</span>`;
html += `</div>`;
html += `</div>`;
});
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".address-row").forEach((row) => {
row.addEventListener("click", () => {
state.selectedWallet = parseInt(row.dataset.wallet, 10);
state.selectedAddress = parseInt(row.dataset.address, 10);
showAddressDetail();
});
});
container.querySelectorAll(".btn-add-address").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const wi = parseInt(btn.dataset.wallet, 10);
const wallet = state.wallets[wi];
const newAddr = deriveAddressFromXpub(
wallet.xpub,
wallet.nextIndex,
);
wallet.addresses.push({
address: newAddr,
balance: "0.0000",
tokenBalances: [],
});
wallet.nextIndex++;
await saveState();
renderWalletList();
});
});
renderTotalValue();
}
async function addWalletAndGoToMain(wallet) {
state.wallets.push(wallet);
state.hasWallet = true;
await saveState();
renderWalletList();
showView("main");
}
function showAddWalletView() {
$("wallet-mnemonic").value = "";
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").classList.add("hidden");
hideError("add-wallet-error");
showView("add-wallet");
}
function showImportKeyView() {
$("import-private-key").value = "";
$("import-key-password").value = "";
$("import-key-password-confirm").value = "";
hideError("import-key-error");
showView("import-key");
}
function backFromWalletAdd() {
if (!state.hasWallet) {
showView("welcome");
} else {
renderWalletList();
showView("main");
}
home.render(ctx);
}
async function doRefreshAndRender() {
@@ -243,7 +31,15 @@ async function doRefreshAndRender() {
renderWalletList();
}
// -- init --
const ctx = {
renderWalletList,
doRefreshAndRender,
showAddWalletView: () => addWallet.show(),
showImportKeyView: () => importKey.show(),
showAddressDetail: () => addressDetail.show(),
showAddTokenView: () => addToken.show(),
};
async function init() {
if (DEBUG) {
const banner = document.createElement("div");
@@ -256,6 +52,18 @@ async function init() {
await loadState();
// Initialize all view event handlers
welcome.init(ctx);
addWallet.init(ctx);
importKey.init(ctx);
home.init(ctx);
addressDetail.init(ctx);
send.init(ctx);
receive.init(ctx);
addToken.init(ctx);
settings.init(ctx);
approval.init(ctx);
if (!state.hasWallet) {
showView("welcome");
} else {
@@ -263,356 +71,6 @@ async function init() {
showView("main");
doRefreshAndRender();
}
// -- Welcome --
$("btn-welcome-add").addEventListener("click", showAddWalletView);
// -- Add wallet --
$("btn-generate-phrase").addEventListener("click", () => {
$("wallet-mnemonic").value = generateMnemonic();
$("add-wallet-phrase-warning").classList.remove("hidden");
});
$("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showError(
"add-wallet-error",
"Please enter a recovery phrase or press the die to generate one.",
);
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showError(
"add-wallet-error",
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
);
return;
}
if (!isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
"Invalid recovery phrase. Please check for typos.",
);
return;
}
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-error", "Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"add-wallet-error",
"Password must be at least 8 characters.",
);
return;
}
if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match.");
return;
}
hideError("add-wallet-error");
const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
],
});
});
$("btn-add-wallet-back").addEventListener("click", backFromWalletAdd);
$("btn-add-wallet-import-key").addEventListener("click", showImportKeyView);
// -- Import private key --
$("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showError("import-key-error", "Please enter your private key.");
return;
}
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
showError("import-key-error", "Invalid private key.");
return;
}
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showError("import-key-error", "Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"import-key-error",
"Password must be at least 8 characters.",
);
return;
}
if (pw !== pw2) {
showError("import-key-error", "Passwords do not match.");
return;
}
hideError("import-key-error");
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "key",
name: "Wallet " + walletNum,
encryptedSecret: encrypted,
addresses: [
{ address: addr, balance: "0.0000", tokenBalances: [] },
],
});
});
$("btn-import-key-back").addEventListener("click", backFromWalletAdd);
// -- Main view --
$("btn-settings").addEventListener("click", () => {
$("settings-rpc").value = state.rpcUrl;
showView("settings");
});
$("btn-main-add-wallet").addEventListener("click", showAddWalletView);
// -- Address detail --
$("address-full").addEventListener("click", () => {
const addr = $("address-full").textContent;
if (addr) {
navigator.clipboard.writeText(addr);
$("address-copied-msg").textContent = "Copied!";
setTimeout(() => {
$("address-copied-msg").textContent = "";
}, 2000);
}
});
$("btn-address-back").addEventListener("click", () => {
renderWalletList();
showView("main");
});
$("btn-send").addEventListener("click", () => {
$("send-to").value = "";
$("send-amount").value = "";
$("send-password").value = "";
$("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
showView("send");
});
$("btn-receive").addEventListener("click", () => {
const addr = currentAddress();
const address = addr ? addr.address : "";
$("receive-address").textContent = address;
if (address) {
QRCode.toCanvas($("receive-qr"), address, {
width: 200,
margin: 2,
color: { dark: "#000000", light: "#ffffff" },
});
}
showView("receive");
});
$("btn-add-token").addEventListener("click", () => {
$("add-token-address").value = "";
$("add-token-info").classList.add("hidden");
hideError("add-token-error");
const list = $("common-token-list");
list.innerHTML = TOKENS.slice(0, 25)
.map(
(t) =>
`<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${t.address}" data-symbol="${t.symbol}" data-decimals="${t.decimals}">${t.symbol}</button>`,
)
.join("");
list.querySelectorAll(".common-token").forEach((btn) => {
btn.addEventListener("click", () => {
$("add-token-address").value = btn.dataset.address;
});
});
showView("add-token");
});
// -- Send --
$("btn-send-confirm").addEventListener("click", async () => {
const to = $("send-to").value.trim();
const amount = $("send-amount").value.trim();
if (!to) {
showError("send-status", "Please enter a recipient address.");
$("send-status").classList.remove("hidden");
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
showError("send-status", "Please enter a valid amount.");
$("send-status").classList.remove("hidden");
return;
}
let resolvedTo = to;
if (to.includes(".") && !to.startsWith("0x")) {
const statusEl = $("send-status");
statusEl.textContent = "Resolving " + to + "...";
statusEl.classList.remove("hidden");
try {
const provider = getProvider(state.rpcUrl);
const resolved = await provider.resolveName(to);
if (!resolved) {
showError("send-status", "Could not resolve " + to);
return;
}
resolvedTo = resolved;
} catch (e) {
showError("send-status", "Failed to resolve ENS name.");
return;
}
}
const password = $("send-password").value;
if (!password) {
showError("send-status", "Please enter your password.");
$("send-status").classList.remove("hidden");
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
const statusEl = $("send-status");
statusEl.textContent = "Decrypting...";
statusEl.classList.remove("hidden");
try {
decryptedSecret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
} catch (e) {
showError("send-status", "Wrong password.");
return;
}
statusEl.textContent = "Sending...";
try {
const signer = getSignerForAddress(
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({
to: resolvedTo,
value: parseEther(amount),
});
statusEl.textContent = "Sent. Waiting for confirmation...";
const receipt = await tx.wait();
statusEl.textContent =
"Confirmed in block " +
receipt.blockNumber +
". Tx: " +
receipt.hash;
invalidateBalanceCache();
doRefreshAndRender();
} catch (e) {
statusEl.textContent = "Failed: " + (e.shortMessage || e.message);
}
});
$("btn-send-back").addEventListener("click", () => showAddressDetail());
// -- Receive --
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address").textContent;
if (addr) navigator.clipboard.writeText(addr);
});
$("btn-receive-back").addEventListener("click", () => showAddressDetail());
// -- Add Token --
$("btn-add-token-confirm").addEventListener("click", async () => {
const contractAddr = $("add-token-address").value.trim();
if (!contractAddr || !contractAddr.startsWith("0x")) {
showError(
"add-token-error",
"Please enter a valid contract address starting with 0x.",
);
return;
}
// Check if already tracked
const already = state.trackedTokens.find(
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
);
if (already) {
showError(
"add-token-error",
already.symbol + " is already being tracked.",
);
return;
}
hideError("add-token-error");
const infoEl = $("add-token-info");
infoEl.textContent = "Looking up token...";
infoEl.classList.remove("hidden");
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
state.trackedTokens.push({
address: contractAddr,
symbol: info.symbol,
decimals: info.decimals,
name: info.name,
});
await saveState();
invalidateBalanceCache();
await refreshBalances(
state.wallets,
state.trackedTokens,
state.rpcUrl,
);
await saveState();
showAddressDetail();
} catch (e) {
showError(
"add-token-error",
"Could not read token contract. Check the address.",
);
infoEl.classList.add("hidden");
}
});
$("btn-add-token-back").addEventListener("click", () =>
showAddressDetail(),
);
// -- Settings --
$("btn-save-rpc").addEventListener("click", async () => {
state.rpcUrl = $("settings-rpc").value.trim();
await saveState();
});
$("btn-settings-back").addEventListener("click", () => {
renderWalletList();
showView("main");
});
// -- Approval --
$("btn-approve").addEventListener("click", () => {
renderWalletList();
showView("main");
});
$("btn-reject").addEventListener("click", () => {
renderWalletList();
showView("main");
});
}
document.addEventListener("DOMContentLoaded", init);