diff --git a/README.md b/README.md index d5eed15..a4505e2 100644 --- a/README.md +++ b/README.md @@ -422,11 +422,12 @@ captured at `eth_sendRawTransaction` rather than against anything the extension reported, rejecting each prompt is required to return a rejection to the page rather than hang or resolve, a network switch request is required to leave the stored network unchanged and send no `chainChanged` until it is approved, a -prompt raised while another approval window has focus is required to open a -window of its own, and the password is required to be absent from every message -the approval window sends to the background — with the message that would carry -it required to be present, so that check cannot pass by observing nothing. That -last one is the standing floor under +network switch in Settings is required to send the page `chainChanged` with the +new chain id, a prompt raised while another approval window has focus is +required to open a window of its own, and the password is required to be absent +from every message the approval window sends to the background — with the +message that would carry it required to be present, so that check cannot pass by +observing nothing. That last one is the standing floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157). The limits of that coverage and of the rest of the Chrome suite, none of them @@ -1789,7 +1790,8 @@ view would leave a wallet one click from deletion. network changes only here, or when the user approves a site's request on **NetworkApproval**; either way, switching restores the RPC and Blockscout endpoints last used on that network, or that network's defaults if it has - none + none, and sends `chainChanged` with the new chain id to every open tab. + Choosing the network already active changes nothing and sends nothing - Ethereum RPC: endpoint URL input + "Save" button (validated against `eth_chainId` before being saved) - Blockscout API: endpoint URL input + "Save" button (validated against diff --git a/TODO.md b/TODO.md index 0fcd7fd..b0ceb21 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,15 @@ then continue tagging as milestones land. # Completed Steps +- 2026-10-08: Switching the network in Settings now tells open pages + ([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is + saved, Settings asks the background to send `chainChanged` with the new chain + id to every open tab, through the same function an approved site request uses. + Choosing the network already active changes nothing and sends nothing. Only + the extension's own pages can ask for this. `tests/chainSwitchGate.test.js` + drives the real Settings view against the background, and the Chrome suite + checks that the test page hears both switches. + - 2026-10-07: A site can no longer switch the wallet's network by itself ([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's `wallet_switchEthereumChain` request for the other supported network opens a diff --git a/src/background/index.js b/src/background/index.js index 609deea..d5a6fc4 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1471,6 +1471,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { "AUTISTMASK_ADDRESSES_REMOVED", "AUTISTMASK_GET_CONNECTED_SITES", "AUTISTMASK_REMOVE_SITE", + "AUTISTMASK_NETWORK_CHANGED", ]; if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) { sendResponse({ error: "Unauthorized sender" }); @@ -1854,6 +1855,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { broadcastSiteRemoved(msg.origin); return false; } + + // Settings switched the network and has saved it. Open tabs are told the + // new chain id the same way as after a site's approved switch request. + if (msg.type === "AUTISTMASK_NETWORK_CHANGED") { + broadcastChainChanged(msg.chainId); + return false; + } }); module.exports = { PROXY_METHODS }; diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index 4661fd4..07c3fbb 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -316,7 +316,11 @@ function init(ctx) { const networkSelect = $("settings-network"); networkSelect.addEventListener("change", async () => { const newId = networkSelect.value; + if (newId === state.networkId) return; const net = await onChainSwitch(newId); + // Open pages are told by the background, as after a site's approved + // switch request. + notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); $("settings-rpc").value = state.rpcUrl; $("settings-blockscout").value = state.blockscoutUrl; showFlash("Switched to " + net.name + "."); diff --git a/tests/chainSwitchGate.test.js b/tests/chainSwitchGate.test.js index dfc557a..560ee08 100644 --- a/tests/chainSwitchGate.test.js +++ b/tests/chainSwitchGate.test.js @@ -16,7 +16,9 @@ // // The endpoint half of #308 lives in tests/networkEndpoints.test.js, which // covers the popup's chain switch; this file covers the background's, which -// goes through storage rather than the shared state singleton. +// goes through storage rather than the shared state singleton. The last block +// covers what the background tells open tabs when the user switches the +// network in Settings. const { networkById } = require("../src/shared/networks"); const { makeStorageStub } = require("./support/storageStub"); @@ -225,6 +227,14 @@ function loadBackground() { answerPrompt, closePrompt, opened, + // A message to the background from `sender`, and its answer. + send: (msg, sender) => { + let reply = null; + messageListener(msg, sender, (r) => { + reply = r; + }); + return reply; + }, walletState: () => storage.read("autistmask"), chainChangedEvents: () => toTabs.filter((m) => m.eventName === "chainChanged"), @@ -390,3 +400,109 @@ describe("only the user switches the network", () => { expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC); }); }); + +// Switching the network in Settings tells open pages, as an approved site +// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive +// the real Settings view over the background's storage, with what it sends +// delivered to the background from the extension's own page. +describe("switching the network in Settings tells every open tab", () => { + const POPUP = { url: EXT_URL + "src/popup/index.html" }; + + // A stand-in for one DOM node: enough of an element for init() to set + // properties on it and hang listeners off it. + function fakeElement() { + return { + value: "", + checked: false, + textContent: "", + style: {}, + dataset: {}, + classList: { add() {}, remove() {} }, + listeners: {}, + addEventListener(event, handler) { + this.listeners[event] = handler; + }, + querySelectorAll: () => [], + }; + } + + // Settings, opened the way the popup opens it. Returns its network + // selector. + async function openSettings(bg) { + const elements = {}; + const element = (id) => (elements[id] ||= fakeElement()); + jest.doMock("../src/popup/views/helpers", () => ({ + $: element, + showView: () => {}, + updateDebugBanner: () => {}, + showFlash: () => {}, + escapeHtml: (s) => s, + flashCopyFeedback: () => {}, + goBack: () => {}, + pushCurrentView: () => {}, + onViewLeave: () => {}, + VIEWS: [], + })); + global.chrome.runtime.sendMessage = (msg) => { + bg.send(msg, POPUP); + }; + await require("../src/shared/state").loadState(); + require("../src/popup/views/settings").init({ + pageClosed: new AbortController().signal, + }); + const select = element("settings-network"); + select.value = "mainnet"; + return select; + } + + // The user picks `networkId` in the selector. + async function choose(select, networkId) { + select.value = networkId; + await select.listeners.change(); + await settle(); + } + + afterEach(() => { + jest.dontMock("../src/popup/views/helpers"); + }); + + test("switching to the other network sends chainChanged once, with its chain id", async () => { + const bg = loadBackground(); + const select = await openSettings(bg); + + await choose(select, "sepolia"); + + expect(bg.walletState().networkId).toBe("sepolia"); + expect(bg.chainChangedEvents()).toEqual([ + { + type: "AUTISTMASK_EVENT", + eventName: "chainChanged", + data: SEPOLIA.chainId, + }, + ]); + }); + + test("choosing the network already active changes nothing and sends nothing", async () => { + const bg = loadBackground(); + const select = await openSettings(bg); + const before = bg.walletState(); + + await choose(select, "mainnet"); + + expect(bg.walletState()).toEqual(before); + expect(bg.chainChangedEvents()).toEqual([]); + }); + + test("a page cannot make the background send chainChanged", async () => { + const bg = loadBackground(); + + const reply = bg.send( + { type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId }, + { url: CONNECTED_ORIGIN + "/" }, + ); + await settle(); + + expect(reply).toEqual({ error: "Unauthorized sender" }); + expect(bg.chainChangedEvents()).toEqual([]); + }); +}); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 63e5002..589fe8b 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -4538,6 +4538,42 @@ test("a site's network switch changes nothing until the user approves it (#408)" ); }); +// Switching the network in Settings tells the page too, as an approved site +// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet +// goes back to mainnet the same way at the end. +test("a network switch in Settings tells the page (#500)", async (env) => { + const { mainnet, sepolia } = NETWORKS; + const before = await storedNetwork(env.page); + assert( + before.networkId === "mainnet", + "this test starts on mainnet, not on " + before.networkId, + ); + const eventsBefore = (await chainChangedEvents(env.dapp)).length; + await openSettings(env.page); + + await env.page.selectOption("#settings-network", "sepolia"); + let events = await chainChangedEvents(env.dapp, eventsBefore + 1); + assert( + events.length === eventsBefore + 1 && + events[events.length - 1].data === sepolia.chainId, + "the page was not told of the switch to Sepolia: " + + JSON.stringify(events), + ); + + await env.page.selectOption("#settings-network", "mainnet"); + events = await chainChangedEvents(env.dapp, eventsBefore + 2); + assert( + events.length === eventsBefore + 2 && + events[events.length - 1].data === mainnet.chainId, + "the page was not told of the switch back to mainnet: " + + JSON.stringify(events), + ); + assert( + isDeepStrictEqual(await storedNetwork(env.page), before), + "switching back did not restore the mainnet network and endpoints", + ); +}); + // The closing pass over both boundaries at once. Every message the section // put on either channel is re-read here and required to be free of the // password — and required to be there at all, method by method, so the