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)