Compare commits

...

2 Commits

Author SHA1 Message Date
clawbot
9a18d6b52f feat: add About well to settings with build info and debug easter egg
All checks were successful
check / check (push) Successful in 14s
Add a new well at the bottom of the settings view that displays:
- License (GPL-3.0)
- Author (sneak)
- Version (from package.json)
- Build date (injected at build time)
- Git commit short hash (linked to Gitea commit URL)

Build-time injection: build.js now reads the git commit hash and version
from package.json, injecting them via esbuild define constants. The
Dockerfile and Makefile pass commit hashes as build args so the info is
available even when .git is excluded from the Docker context.

Easter egg: clicking the version number 10 times reveals a hidden debug
well below the About well, containing a toggle for debug mode. The debug
mode flag is persisted in state and enables verbose console logging via
the runtime debug flag in the logger.

closes #144
2026-03-01 11:42:19 -08:00
a138a36710 fix: suppress USD display on testnet networks (#142)
All checks were successful
check / check (push) Successful in 24s
## Summary

Fixes USD prices still showing on the main view when connected to a testnet (e.g. Sepolia). The root cause was stale mainnet prices lingering in the in-memory price cache after switching networks.

### Root Cause

PR #137 correctly made `refreshPrices()` skip fetching on testnets, but the cached prices from a prior mainnet session remained in the `prices` object. All display functions (`getPrice()`, `getAddressValueUsd()`, etc.) used whatever was cached without checking which network was active.

### Changes

- **`src/shared/prices.js`**
  - `refreshPrices()` now clears the price cache when on a testnet instead of silently returning
  - New `clearPrices()` function empties the cache and resets the fetch timestamp
  - `getPrice()` returns null on testnets (defense-in-depth)
  - `getAddressValueUsd()`, `getWalletValueUsd()`, `getTotalValueUsd()` return null on testnets

- **`src/popup/views/settings.js`**
  - Network switcher immediately clears prices when switching to a testnet, so the UI updates without waiting for the next refresh cycle

closes #139

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #142
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 20:31:45 +01:00
12 changed files with 311 additions and 20 deletions

View File

@@ -11,5 +11,10 @@ RUN yarn install --frozen-lockfile
COPY . . COPY . .
ARG GIT_COMMIT_SHORT=unknown
ARG GIT_COMMIT_FULL=unknown
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV GIT_COMMIT_FULL=${GIT_COMMIT_FULL}
RUN make check RUN make check
RUN make build RUN make build

View File

@@ -33,7 +33,10 @@ dev:
@yarn run build --watch 2>&1 @yarn run build --watch 2>&1
docker: docker:
@docker build -t autistmask . @docker build \
--build-arg GIT_COMMIT_SHORT=$$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
--build-arg GIT_COMMIT_FULL=$$(git rev-parse HEAD 2>/dev/null || echo unknown) \
-t autistmask .
hooks: hooks:
@echo "Installing pre-commit hook..." @echo "Installing pre-commit hook..."

View File

@@ -11,9 +11,55 @@ function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
function getBuildInfo() {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
let commitHash = process.env.GIT_COMMIT_SHORT || "unknown";
if (commitHash === "unknown") {
try {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
}
let commitHashFull = process.env.GIT_COMMIT_FULL || "unknown";
if (commitHashFull === "unknown") {
try {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
// not a git repo or git not available
}
}
return {
version: pkg.version,
license: pkg.license,
author: pkg.author,
commitHash,
commitHashFull,
buildDate: new Date().toISOString().slice(0, 10),
};
}
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo);
const define = {
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
__BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
__BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
__BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
};
// compile tailwind CSS // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
@@ -38,6 +84,7 @@ async function build() {
platform: "browser", platform: "browser",
target: ["chrome110", "firefox110"], target: ["chrome110", "firefox110"],
minify: true, minify: true,
define,
}); });
// bundle background script // bundle background script
@@ -49,6 +96,7 @@ async function build() {
platform: "browser", platform: "browser",
target: ["chrome110", "firefox110"], target: ["chrome110", "firefox110"],
minify: true, minify: true,
define,
}); });
// bundle content script // bundle content script
@@ -60,6 +108,7 @@ async function build() {
platform: "browser", platform: "browser",
target: ["chrome110", "firefox110"], target: ["chrome110", "firefox110"],
minify: true, minify: true,
define,
}); });
// bundle inpage script (injected into page context, separate file) // bundle inpage script (injected into page context, separate file)
@@ -71,6 +120,7 @@ async function build() {
platform: "browser", platform: "browser",
target: ["chrome110", "firefox110"], target: ["chrome110", "firefox110"],
minify: true, minify: true,
define,
}); });
// copy popup HTML // copy popup HTML

View File

@@ -4,6 +4,7 @@
const { DEFAULT_RPC_URL } = require("../shared/constants"); const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const { getBytes } = require("ethers"); const { getBytes } = require("ethers");
const { const {
state, state,
@@ -345,12 +346,8 @@ async function handleRpc(method, params, origin) {
return { result: null }; return { result: null };
} }
if (SUPPORTED_CHAIN_IDS.has(chainId)) { if (SUPPORTED_CHAIN_IDS.has(chainId)) {
// Switch to the requested network
const target = networkByChainId(chainId); const target = networkByChainId(chainId);
state.networkId = target.id; await onChainSwitch(target.id);
state.rpcUrl = target.defaultRpcUrl;
state.blockscoutUrl = target.defaultBlockscoutUrl;
await saveState();
broadcastChainChanged(target.chainId); broadcastChainChanged(target.chainId);
return { result: null }; return { result: null };
} }

View File

@@ -1002,6 +1002,54 @@
</p> </p>
<div id="settings-denied-sites"></div> <div id="settings-denied-sites"></div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">About</h3>
<div class="text-xs">
<div class="mb-1">
<span class="text-muted">License:</span>
<span id="about-license"></span>
</div>
<div class="mb-1">
<span class="text-muted">Author:</span>
<span id="about-author"></span>
</div>
<div class="mb-1">
<span class="text-muted">Version:</span>
<span
id="about-version"
class="cursor-pointer select-none"
></span>
</div>
<div class="mb-1">
<span class="text-muted">Build date:</span>
<span id="about-build-date"></span>
</div>
<div>
<span class="text-muted">Commit:</span>
<a
id="about-commit-link"
class="underline decoration-dashed"
target="_blank"
rel="noopener noreferrer"
></a>
</div>
</div>
</div>
<div
id="settings-debug-well"
class="bg-well p-3 mx-1 mb-3"
style="display: none"
>
<h3 class="font-bold mb-1">Debug</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer"
>
<input type="checkbox" id="settings-debug-mode" />
Enable debug mode
</label>
</div>
</div> </div>
<!-- ============ DELETE WALLET CONFIRM ============ --> <!-- ============ DELETE WALLET CONFIRM ============ -->

View File

@@ -51,7 +51,7 @@ function etherscanAddressLink(address) {
} }
function etherscanTokenLink(tokenContract, holderAddress) { function etherscanTokenLink(tokenContract, holderAddress) {
return `https://etherscan.io/token/${tokenContract}?a=${holderAddress}`; return `${currentNetwork().explorerUrl}/token/${tokenContract}?a=${holderAddress}`;
} }
function isoDate(timestamp) { function isoDate(timestamp) {
@@ -168,7 +168,7 @@ function show() {
`<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`; `<a href="${addrLink}" target="_blank" rel="noopener" class="inline-flex items-center">${EXT_ICON}</a>`;
// USD total for this token only // USD total for this token only
const usdVal = price ? amount * price : 0; const usdVal = price ? amount * price : null;
const usdStr = formatUsd(usdVal); const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;"; $("address-token-usd-total").innerHTML = usdStr || "&nbsp;";

View File

@@ -2,12 +2,24 @@ const { $, showView, showFlash, escapeHtml } = require("./helpers");
const { applyTheme } = require("../theme"); const { applyTheme } = require("../theme");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks"); const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { log, debugFetch } = require("../../shared/log"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_DATE,
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime = const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime; typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
let versionClickCount = 0;
let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) { function renderSiteList(containerId, siteMap, stateKey) {
const container = $(containerId); const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())]; const hostnames = [...new Set(Object.values(siteMap).flat())];
@@ -133,6 +145,28 @@ function show() {
renderSiteLists(); renderSiteLists();
renderWalletListSettings(); 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-build-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
const debugWell = $("settings-debug-well");
if (state.debugMode) {
debugWell.style.display = "";
} else {
debugWell.style.display = "none";
}
$("settings-debug-mode").checked = state.debugMode;
showView("settings"); showView("settings");
} }
@@ -220,14 +254,9 @@ function init(ctx) {
if (networkSelect) { if (networkSelect) {
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
const net = NETWORKS[newId]; const net = await onChainSwitch(newId);
if (!net) return;
state.networkId = newId;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
await saveState();
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
} }
@@ -285,6 +314,31 @@ function init(ctx) {
ctx.showSettingsAddTokenView, ctx.showSettingsAddTokenView,
); );
// Easter egg: click version 10 times to reveal the debug well
$("about-version").addEventListener("click", () => {
versionClickCount++;
clearTimeout(versionClickTimer);
// Reset counter if user stops clicking for 3 seconds
versionClickTimer = setTimeout(() => {
versionClickCount = 0;
}, 3000);
if (versionClickCount >= 10) {
versionClickCount = 0;
clearTimeout(versionClickTimer);
$("settings-debug-well").style.display = "";
}
});
// Debug mode toggle
$("settings-debug-mode").addEventListener("change", async () => {
state.debugMode = $("settings-debug-mode").checked;
setRuntimeDebug(state.debugMode);
await saveState();
});
// Sync runtime debug flag on init
setRuntimeDebug(state.debugMode);
$("btn-settings-back").addEventListener("click", () => { $("btn-settings-back").addEventListener("click", () => {
ctx.renderWalletList(); ctx.renderWalletList();
showView("main"); showView("main");

35
src/shared/buildInfo.js Normal file
View File

@@ -0,0 +1,35 @@
// Build-time constants injected by esbuild define in build.js.
// These globals are replaced at bundle time with string literals.
/* global __BUILD_VERSION__, __BUILD_LICENSE__, __BUILD_AUTHOR__,
__BUILD_COMMIT__, __BUILD_COMMIT_FULL__, __BUILD_DATE__ */
const BUILD_VERSION =
typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : "dev";
const BUILD_LICENSE =
typeof __BUILD_LICENSE__ !== "undefined" ? __BUILD_LICENSE__ : "GPL-3.0";
const BUILD_AUTHOR =
typeof __BUILD_AUTHOR__ !== "undefined"
? __BUILD_AUTHOR__
: "sneak <sneak@sneak.berlin>";
const BUILD_COMMIT =
typeof __BUILD_COMMIT__ !== "undefined" ? __BUILD_COMMIT__ : "unknown";
const BUILD_COMMIT_FULL =
typeof __BUILD_COMMIT_FULL__ !== "undefined"
? __BUILD_COMMIT_FULL__
: "unknown";
const BUILD_DATE =
typeof __BUILD_DATE__ !== "undefined" ? __BUILD_DATE__ : "unknown";
const GITEA_COMMIT_URL =
"https://git.eeqj.de/sneak/AutistMask/commit/" + BUILD_COMMIT_FULL;
module.exports = {
BUILD_VERSION,
BUILD_LICENSE,
BUILD_AUTHOR,
BUILD_COMMIT,
BUILD_COMMIT_FULL,
BUILD_DATE,
GITEA_COMMIT_URL,
};

57
src/shared/chainSwitch.js Normal file
View File

@@ -0,0 +1,57 @@
// Consolidated chain-switch handler.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
const { networkById } = require("./networks");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = networkById(newNetworkId);
// --- core identity ---
state.networkId = net.id;
state.rpcUrl = net.defaultRpcUrl;
state.blockscoutUrl = net.defaultBlockscoutUrl;
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.).
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;
}
module.exports = { onChainSwitch };

View File

@@ -1,12 +1,27 @@
// Leveled logger. Outputs to console with [AutistMask] prefix. // Leveled logger. Outputs to console with [AutistMask] prefix.
// Level is DEBUG when the DEBUG constant is true, INFO otherwise. // Level is DEBUG when the compile-time DEBUG constant is true or the runtime
// debugMode state flag is enabled. The runtime flag is checked lazily so it
// responds immediately when toggled in settings.
const { DEBUG } = require("./constants"); const { DEBUG } = require("./constants");
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const threshold = DEBUG ? LEVELS.debug : LEVELS.info;
// Runtime debug mode flag — set by settings.js when the user toggles debug
// mode via the easter egg. Kept here as a simple mutable reference so it can
// be updated without circular dependency issues with state.js.
let _runtimeDebug = false;
function setRuntimeDebug(enabled) {
_runtimeDebug = enabled;
}
function isDebug() {
return DEBUG || _runtimeDebug;
}
function emit(level, method, args) { function emit(level, method, args) {
const threshold = isDebug() ? LEVELS.debug : LEVELS.info;
if (LEVELS[level] >= threshold) { if (LEVELS[level] >= threshold) {
console[method]("[AutistMask]", ...args); console[method]("[AutistMask]", ...args);
} }
@@ -37,4 +52,4 @@ async function debugFetch(url, opts) {
return resp; return resp;
} }
module.exports = { log, debugFetch }; module.exports = { log, debugFetch, setRuntimeDebug, isDebug };

View File

@@ -8,9 +8,13 @@ const prices = {};
let lastFetchedAt = 0; let lastFetchedAt = 0;
async function refreshPrices() { async function refreshPrices() {
// Testnet tokens have no real market value — skip price fetching. // Testnet tokens have no real market value — skip price fetching
// and clear any stale mainnet prices so the UI shows no USD values.
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return; if (currentNetwork().isTestnet) {
clearPrices();
return;
}
const now = Date.now(); const now = Date.now();
if (now - lastFetchedAt < PRICE_CACHE_TTL) return; if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
try { try {
@@ -22,7 +26,19 @@ async function refreshPrices() {
} }
} }
// Clear all cached prices and reset the fetch timestamp so the
// next refreshPrices() call will fetch fresh data.
function clearPrices() {
for (const key of Object.keys(prices)) {
delete prices[key];
}
lastFetchedAt = 0;
}
// Return the USD price for a symbol, or null on testnet / unknown.
function getPrice(symbol) { function getPrice(symbol) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
return prices[symbol] || null; return prices[symbol] || null;
} }
@@ -40,6 +56,8 @@ function formatUsd(amount) {
} }
function getAddressValueUsd(addr) { function getAddressValueUsd(addr) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
const ethBal = parseFloat(addr.balance || "0"); const ethBal = parseFloat(addr.balance || "0");
@@ -54,6 +72,8 @@ function getAddressValueUsd(addr) {
} }
function getWalletValueUsd(wallet) { function getWalletValueUsd(wallet) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
@@ -63,6 +83,8 @@ function getWalletValueUsd(wallet) {
} }
function getTotalValueUsd(wallets) { function getTotalValueUsd(wallets) {
const { currentNetwork } = require("./state");
if (currentNetwork().isTestnet) return null;
if (!prices.ETH) return null; if (!prices.ETH) return null;
let total = 0; let total = 0;
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -74,6 +96,7 @@ function getTotalValueUsd(wallets) {
module.exports = { module.exports = {
prices, prices,
refreshPrices, refreshPrices,
clearPrices,
getPrice, getPrice,
formatUsd, formatUsd,
getAddressValueUsd, getAddressValueUsd,

View File

@@ -29,6 +29,7 @@ const DEFAULT_STATE = {
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
debugMode: false,
}; };
const state = { const state = {
@@ -67,6 +68,7 @@ async function saveState() {
fraudContracts: state.fraudContracts, fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache, tokenHolderCache: state.tokenHolderCache,
theme: state.theme, theme: state.theme,
debugMode: state.debugMode,
currentView: state.currentView, currentView: state.currentView,
selectedWallet: state.selectedWallet, selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress, selectedAddress: state.selectedAddress,
@@ -126,6 +128,8 @@ async function loadState() {
state.fraudContracts = saved.fraudContracts || []; state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {}; state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system"; state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null; state.currentView = saved.currentView || null;
state.selectedWallet = state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null; saved.selectedWallet !== undefined ? saved.selectedWallet : null;