From 83e1382812f1d4c815a63360da56b49f79d690a9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 25 Sep 2026 08:01:02 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20extension=20updates=20install=20the=20ne?= =?UTF-8?q?w=20build=20=E2=80=94=20no=2012h-cached=20"latest"=20XPI,=20and?= =?UTF-8?q?=20the=20popup's=20Update=20opens=20FC's=20install=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator, 2026-09-25: "the extension update trigger from inside the extension doesn't work and the manual update seems to not move it to the most recent version or at least mark it the most recent." - fabledcurator-latest.xpi was served with Quart's default `public, max-age=43200`: one URL whose bytes change every release, so a browser that had fetched it reinstalled the previous build for 12 hours (measured on the instance). It is now `no-cache` (the ETag keeps an unchanged file a 304); versioned XPIs are `immutable`. - The web Settings card installs/downloads the VERSIONED xpi_url, which can only ever be that build's bytes. - The popup's Update button did tabs.create() on the .xpi, which Firefox refuses (NS_ERROR_FAILURE on a 200: it only installs from a user click on a web page). It now opens FC's install card (/subscriptions?tab=settings). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR --- backend/app/frontend.py | 22 +++++++++++++++++-- extension/background/background.js | 7 +++++- extension/popup/popup.js | 12 ++++++---- .../settings/BrowserExtensionCard.vue | 8 +++++-- tests/test_api_extension.py | 22 +++++++++++++++++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/backend/app/frontend.py b/backend/app/frontend.py index 8bf97ee..b7925bd 100644 --- a/backend/app/frontend.py +++ b/backend/app/frontend.py @@ -46,6 +46,14 @@ async def serve_extension(filename: str): The application/x-xpinstall MIME tells Firefox to show its native install prompt instead of downloading the file as a blob. + + Caching differs by name, and has to. A versioned name is one build's bytes + forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE + URL whose bytes change on every release, and Quart's default for a file + is `public, max-age=43200`: a browser that fetched it once reused those + bytes for 12 hours, so "install the latest" quietly reinstalled the + previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag + still makes an unchanged file a cheap 304. """ if not _XPI_NAME_RE.fullmatch(filename): abort(404) @@ -56,10 +64,11 @@ async def serve_extension(filename: str): if not xpis: abort(404) latest = xpis[-1] - return await send_file( + resp = await send_file( latest, mimetype="application/x-xpinstall", attachment_filename=latest.name, ) + return _cache(resp, "no-cache") target = (XPI_DIR / filename).resolve() try: target.relative_to(XPI_DIR) @@ -67,10 +76,19 @@ async def serve_extension(filename: str): abort(404) if not target.is_file(): abort(404) - return await send_file( + resp = await send_file( target, mimetype="application/x-xpinstall", attachment_filename=filename, ) + return _cache(resp, "public, max-age=31536000, immutable") + + +def _cache(resp, policy: str): + """Set the XPI's Cache-Control, dropping the Expires send_file adds so the + two can never disagree.""" + resp.headers["Cache-Control"] = policy + resp.headers.pop("Expires", None) + return resp @frontend_bp.route("/") diff --git a/extension/background/background.js b/extension/background/background.js index d7e82c5..f41d117 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -88,7 +88,12 @@ async function checkForUpdateInfo() { currentVersion, latestVersion, channel, - xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null, + // Where the Update button sends the operator: FC's own install card, not + // the XPI. Firefox refuses an add-on install whose navigation an extension + // started (tabs.create on the .xpi dies with NS_ERROR_FAILURE — operator- + // flagged 2026-09-25); it accepts one from a user click on a web page, + // which is exactly what the card's Install button is. + installPageUrl: base ? `${base}/subscriptions?tab=settings` : null, }; } diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 1874c9b..1fe7410 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -76,7 +76,7 @@ function updateConnectionDot(connected) { async function checkForUpdate() { try { const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' }); - if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r); + if (r && r.updateAvailable && r.installPageUrl) showUpdateBanner(r); } catch { /* non-fatal */ } } @@ -86,10 +86,14 @@ function showUpdateBanner(r) { // exactly as it did before the field existed. const channel = r.channel ? ` (${r.channel})` : ''; document.getElementById('update-text').textContent = - `Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`; - // Opening the signed XPI triggers Firefox's native install prompt. + `Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion}). ` + + 'Opens FabledCurator — click “Install Firefox extension” there.'; + // Opens FC's install card rather than the XPI: Firefox only installs an + // add-on from a user click on a web page, never from a tab an extension + // opened on the .xpi itself. document.getElementById('update-btn').addEventListener('click', () => { - browser.tabs.create({ url: r.xpiUrl }); + browser.tabs.create({ url: r.installPageUrl }); + window.close(); }); document.getElementById('update-banner').classList.remove('hidden'); } diff --git a/frontend/src/components/settings/BrowserExtensionCard.vue b/frontend/src/components/settings/BrowserExtensionCard.vue index 50b6a6a..0b96c0e 100644 --- a/frontend/src/components/settings/BrowserExtensionCard.vue +++ b/frontend/src/components/settings/BrowserExtensionCard.vue @@ -49,16 +49,20 @@ sometimes triggered nothing instead of the install dialog (operator-flagged 2026-05-26). No `download` attribute — that would force a save dialog instead of install. --> + Install Firefox extension Download XPI diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index f1dfee5..24f1c31 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -779,6 +779,28 @@ async def test_serve_extension_latest_returns_most_recent_xpi( assert data == b"new" +@pytest.mark.asyncio +async def test_the_latest_alias_is_never_served_from_a_stale_cache( + client, monkeypatch, tmp_path, +): + """One URL whose bytes change every release: a cached copy reinstalls the + previous build (operator-flagged 2026-09-25, when it was max-age=43200).""" + (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new") + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-latest.xpi") + assert resp.headers["Cache-Control"] == "no-cache" + assert "Expires" not in resp.headers + + +@pytest.mark.asyncio +async def test_a_versioned_xpi_is_cached_for_good(client, monkeypatch, tmp_path): + """A versioned name is one build's bytes forever.""" + (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new") + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-1.0.1.xpi") + assert "immutable" in resp.headers["Cache-Control"] + + @pytest.mark.asyncio async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path): monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)