From 7d841b39f6fd738e8c54caeb617a4424b3f7c2d3 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 11:37:24 +0000 Subject: [PATCH] fix: approval windows open as wide as the page (closes #518) The approval window was opened 360 pixels wide while the page is 396, so it scrolled sideways and cut off the Reject button. It now opens at 396. The browser counts the window's frame in that width, and a tall screen gives some of it to a vertical scrollbar, so once the approval's screen is up the page widens its own window by however far it would scroll sideways, times the page's zoom. The toolbar popup's window is left alone. The Firefox suite checks that the sign prompt does not scroll sideways; the Chrome suite cannot, as its approval windows open at 1280x720 whatever size is asked. README's Visual Style line now gives the page's real width. Model: opus-5-5 --- README.md | 15 +++++-- src/background/index.js | 7 ++- src/popup/index.js | 30 ++++++++++++- src/shared/browserApi.js | 27 ++++++++++++ tests/approvalWindow.test.js | 74 ++++++++++++++++++++++++++++++++ tests/backgroundApproval.test.js | 6 +-- tests/e2e/firefox/run.js | 11 +++++ tests/support/popupBoot.js | 14 ++++++ 8 files changed, 174 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d08f843..d7c3296 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,9 @@ papered over: forced by clicking Reveal and the settings gear in one page task, which a person cannot do; the case a person can hit, the first decrypt after the popup opens while libsodium is still loading, is not driven. +- Approval windows open at 1280x720 whatever size the extension asks for, so + whether an approval screen fits its window is checked in the Firefox suite + only. Any test that drives a failure path on purpose declares the `console.error` it is about to provoke, via `errors.expect()`. That is not a mute: the declaration @@ -551,7 +554,8 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` rejected and then approved, `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001 — driven through the real content script, -background page and approval windows. +background page and approval windows. The `personal_sign` prompt is also +required to fit its window without scrolling sideways. The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has **no npm dependencies at all**: it is built on global `fetch` and @@ -908,8 +912,13 @@ discoverable. enough that vanilla JS with simple view switching is sufficient. A framework would add bundle size, build complexity, and attack surface for no benefit at this scale. -- **360x600 popup**: Standard browser extension popup dimensions. The UI is - designed for this fixed viewport. +- **396x600 popup**: The page is 396 pixels wide (`body` in + `src/popup/styles/main.css`), and 600 pixels is as tall as the browser lets + the toolbar popup grow. The UI is designed for this fixed viewport. The window + an approval opens in is given the same size. The browser counts the window's + frame in that width, and a screen taller than the window gives some of it to a + vertical scrollbar, so once the approval's screen is up the page widens its + window by however far it would scroll sideways, scaled by the browser's zoom. #### No Layout Shift diff --git a/src/background/index.js b/src/background/index.js index 2b55689..b81d7fb 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -423,7 +423,10 @@ function releaseApproval(approval) { // the result on the floor, so a rejection here would be unhandled. async function openApprovalWindow(id) { const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); - const popupWidth = 360; + // As wide as the page: body in src/popup/styles/main.css. The browser + // counts the window's frame in this width; the page itself adds back + // whatever the frame takes (src/popup/index.js). + const popupWidth = 396; const popupHeight = 600; let currentWin = null; @@ -443,7 +446,7 @@ async function openApprovalWindow(id) { // Centred on a browser window only. The last focused window can be // another approval window still open, and centring on one can give a // position the browser refuses ("Bounds must be at least 50% within - // visible screen space"): headless Chrome reports this 360x600 popup as + // visible screen space"): headless Chrome reports this 396x600 popup as // 1280x720. The request then failed with no window at all. Over a popup, // the browser picks the position. if (currentWin && currentWin.type === "normal") { diff --git a/src/popup/index.js b/src/popup/index.js index 6999cc9..cd7bf6e 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -21,6 +21,11 @@ const { goBack, } = require("./views/helpers"); const { applyTheme } = require("./theme"); +const { + tabsGetZoom, + windowsGetCurrent, + windowsUpdate, +} = require("../shared/browserApi"); // Renders a view the popup lands on without having navigated to it forward: // on restore here, and on Back. Only the views that can be fully re-rendered // from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go @@ -163,6 +168,27 @@ function fallbackView() { showView("main"); } +// The background opens the approval window as wide as the page, but the +// browser counts the window's frame in that width, and a screen taller than +// the window takes a vertical scrollbar out of it too. So once the screen is +// up, the window is widened by however far the page would scroll sideways. +// That distance is in page pixels and the window's width in screen pixels: +// at 150% zoom one page pixel is 1.5 screen pixels. The connection prompt can +// also show in the toolbar popup, whose window is the user's browser window; +// that one is left alone. +async function widenApprovalWindow() { + const sideways = + document.documentElement.scrollWidth - + document.documentElement.clientWidth; + if (sideways <= 0) return; + const win = await windowsGetCurrent(); + if (win.type !== "popup") return; + const zoom = await tabsGetZoom(); + await windowsUpdate(win.id, { + width: win.width + Math.ceil(sideways * zoom), + }); +} + async function init() { // First, before anything can save: showView() saves on every navigation // without awaiting, so a save that fails from here on has somewhere to be @@ -239,8 +265,8 @@ async function init() { // the run on both harnesses (Playwright `pageerror`, and the Firefox // driver's console-service drain), so nothing is lost by leaving it // on that path. show() puts the approval's own screen up once the - // background has described it. - approval.show(approvalId); + // background has described it; the window is widened to fit it then. + approval.show(approvalId).then(widenApprovalWindow); return; } diff --git a/src/shared/browserApi.js b/src/shared/browserApi.js index c512286..b214b11 100644 --- a/src/shared/browserApi.js +++ b/src/shared/browserApi.js @@ -235,6 +235,14 @@ function tabsSendMessage(tabId, message) { return invoke(tabsApi(), "sendMessage", tabId, message); } +/** + * @returns {Promise} the zoom of the active tab in the calling page's + * window: 1.5 at 150%. + */ +function tabsGetZoom() { + return invoke(tabsApi(), "getZoom"); +} + /** * @param {Object} createData * @returns {Promise} the created window. @@ -250,6 +258,22 @@ function windowsGetLastFocused() { return invoke(windowsApi(), "getLastFocused"); } +/** + * @returns {Promise} the window the calling page is in. + */ +function windowsGetCurrent() { + return invoke(windowsApi(), "getCurrent"); +} + +/** + * @param {number} windowId + * @param {Object} updateInfo + * @returns {Promise} the updated window. + */ +function windowsUpdate(windowId, updateInfo) { + return invoke(windowsApi(), "update", windowId, updateInfo); +} + /** * @param {number} windowId * @returns {Promise} @@ -271,10 +295,13 @@ module.exports = { storageRemove, storageSet, tabsApi, + tabsGetZoom, tabsQuery, tabsSendMessage, windowsApi, windowsCreate, + windowsGetCurrent, windowsGetLastFocused, windowsRemove, + windowsUpdate, }; diff --git a/tests/approvalWindow.test.js b/tests/approvalWindow.test.js index 6f81442..cb0983b 100644 --- a/tests/approvalWindow.test.js +++ b/tests/approvalWindow.test.js @@ -45,3 +45,77 @@ test("the approval window shows no screen until the background describes the app expect(env.visibleViews()).toEqual(["approve-network"]); expect(env.pageErrors).toEqual([]); }); + +// The background opens the approval window as wide as the page, but the +// browser counts the window's frame in that width, and a screen taller than +// the window takes a vertical scrollbar out of it too +// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run +// without frames and at 100% zoom, so the widening is checked here. +function windowsStub(type) { + const updates = []; + return { + updates, + getCurrent: (done) => done({ id: 7, type, width: 396 }), + update: (id, info, done) => { + updates.push({ id, info }); + done({}); + }, + }; +} + +// An approval window at 150% zoom, whose background holds its answer until +// the test gives it. +async function bootZoomedApprovalWindow(windows) { + let answer = null; + const env = await bootPopup(unversionedValidProfile(), { + search: "?approval=approval-1", + runtime: { + connect: () => ({ postMessage: () => {} }), + sendMessage: (msg, reply) => { + if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply; + }, + }, + windows, + tabs: { getZoom: (done) => done(1.5) }, + // At 150% a window 396 screen pixels wide shows 264 page pixels. + documentElement: { scrollWidth: 396, clientWidth: 264 }, + }); + return { env, answer }; +} + +test("at 150% zoom the approval window widens by what its screen lacks, scrollbar included", async () => { + const windows = windowsStub("popup"); + const { env, answer } = await bootZoomedApprovalWindow(windows); + + // Not before there is a screen to fit. + expect(windows.updates).toEqual([]); + + // The screen is taller than the window, so once it is up a vertical + // scrollbar takes 15 screen pixels, 10 page pixels at 150%. + env.document.documentElement.clientWidth = 254; + answer({ + type: "network", + origin: "https://dapp.example", + currentNetworkId: "mainnet", + requestedNetworkId: "sepolia", + isPhishingDomain: false, + }); + await settle(); + + // 396 - 254 = 142 page pixels short, which at 150% is 213 screen pixels. + expect(env.visibleViews()).toEqual(["approve-network"]); + expect(windows.updates).toEqual([{ id: 7, info: { width: 609 } }]); + expect(env.pageErrors).toEqual([]); +}); + +test("a connection prompt in the toolbar popup leaves the browser window alone", async () => { + const windows = windowsStub("normal"); + const { env, answer } = await bootZoomedApprovalWindow(windows); + + answer({ origin: "https://dapp.example", isPhishingDomain: false }); + await settle(); + + expect(env.visibleViews()).toEqual(["approve-site"]); + expect(windows.updates).toEqual([]); + expect(env.pageErrors).toEqual([]); +}); diff --git a/tests/backgroundApproval.test.js b/tests/backgroundApproval.test.js index 07218fd..083b550 100644 --- a/tests/backgroundApproval.test.js +++ b/tests/backgroundApproval.test.js @@ -2854,7 +2854,7 @@ describe("where an approval window opens", () => { await settle(); expect(bg.created).toHaveLength(1); - expect(bg.created[0]).toMatchObject({ left: 460, top: 60 }); + expect(bg.created[0]).toMatchObject({ left: 442, top: 60 }); }); test("not centred on an approval window the user was last in", async () => { @@ -2871,7 +2871,7 @@ describe("where an approval window opens", () => { bg.requestSign(); await settle(); - // Centred, it would be at left 900, the position the browser refused. + // Centred, it would be at left 882, the position the browser refused. expect(bg.created).toHaveLength(1); expect(bg.created[0].left).toBeUndefined(); expect(bg.created[0].top).toBeUndefined(); @@ -2893,7 +2893,7 @@ describe("where an approval window opens", () => { await settle(); expect(bg.created).toHaveLength(2); - expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 }); + expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 }); expect(bg.created[1].left).toBeUndefined(); expect(bg.created[1].top).toBeUndefined(); diff --git a/tests/e2e/firefox/run.js b/tests/e2e/firefox/run.js index 14d6f31..2389119 100644 --- a/tests/e2e/firefox/run.js +++ b/tests/e2e/firefox/run.js @@ -640,6 +640,17 @@ step( await d.switchToWindow(popup); await d.waitVisible("#view-approve-sign"); + // The window is opened as wide as the page, and widened once the + // screen is up if it still scrolls sideways + // (https://git.eeqj.de/sneak/AutistMask/issues/518). This suite is + // where that is checked: the Chrome suite's approval windows open at + // 1280x720 whatever width the extension asks for. + await d.waitFor( + "the sign prompt to fit its window without scrolling sideways", + "return document.documentElement.scrollWidth <=" + + " document.documentElement.clientWidth;", + ); + const screen = await d.execute( `return { origin: document.getElementById("approve-sign-origin").textContent, diff --git a/tests/support/popupBoot.js b/tests/support/popupBoot.js index 308d0d4..8292f30 100644 --- a/tests/support/popupBoot.js +++ b/tests/support/popupBoot.js @@ -242,6 +242,13 @@ async function settle() { * "?approval=" and an id for the popup opened as an approval window. * @param {object} [options.runtime] members of chrome.runtime that replace the * stub's own, for a test that has to answer the background's messages. + * @param {object} [options.windows] chrome.windows, for a test of the approval + * window resizing itself. + * @param {object} [options.tabs] chrome.tabs, for the same test: the page's + * zoom. + * @param {object} [options.documentElement] members of document.documentElement + * that replace the stub's own: scrollWidth and clientWidth, which are equal + * unless a test says otherwise, so the page does not scroll sideways. * @returns {Promise} handles onto the booted page. */ async function bootPopup(stored, options) { @@ -277,6 +284,11 @@ async function bootPopup(stored, options) { (options && options.storage) || makeStorageStub(stored === undefined ? {} : { autistmask: stored }); const document = makeDocument(POPUP_HTML); + Object.assign( + document.documentElement, + { scrollWidth: 396, clientWidth: 396 }, + options && options.documentElement, + ); const reloads = []; globalThis.chrome = { @@ -287,6 +299,8 @@ async function bootPopup(stored, options) { onMessage: { addListener: () => {} }, ...(options && options.runtime), }, + windows: options && options.windows, + tabs: options && options.tabs, }; globalThis.document = document; globalThis.window = { -- 2.54.0