fix: extension updates install the new build — no 12h-cached "latest" XPI, and the popup's Update opens FC's install page
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m29s
CI and images / build-web (push) Successful in 1m43s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 2s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-25 08:01:02 -04:00
co-authored by Claude Opus 5.5
parent 423275a1e5
commit 83e1382812
5 changed files with 62 additions and 9 deletions
+20 -2
View File
@@ -46,6 +46,14 @@ async def serve_extension(filename: str):
The application/x-xpinstall MIME tells Firefox to show its native The application/x-xpinstall MIME tells Firefox to show its native
install prompt instead of downloading the file as a blob. 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): if not _XPI_NAME_RE.fullmatch(filename):
abort(404) abort(404)
@@ -56,10 +64,11 @@ async def serve_extension(filename: str):
if not xpis: if not xpis:
abort(404) abort(404)
latest = xpis[-1] latest = xpis[-1]
return await send_file( resp = await send_file(
latest, mimetype="application/x-xpinstall", latest, mimetype="application/x-xpinstall",
attachment_filename=latest.name, attachment_filename=latest.name,
) )
return _cache(resp, "no-cache")
target = (XPI_DIR / filename).resolve() target = (XPI_DIR / filename).resolve()
try: try:
target.relative_to(XPI_DIR) target.relative_to(XPI_DIR)
@@ -67,10 +76,19 @@ async def serve_extension(filename: str):
abort(404) abort(404)
if not target.is_file(): if not target.is_file():
abort(404) abort(404)
return await send_file( resp = await send_file(
target, mimetype="application/x-xpinstall", target, mimetype="application/x-xpinstall",
attachment_filename=filename, 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("/") @frontend_bp.route("/")
+6 -1
View File
@@ -88,7 +88,12 @@ async function checkForUpdateInfo() {
currentVersion, currentVersion,
latestVersion, latestVersion,
channel, 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,
}; };
} }
+8 -4
View File
@@ -76,7 +76,7 @@ function updateConnectionDot(connected) {
async function checkForUpdate() { async function checkForUpdate() {
try { try {
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' }); 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 */ } } catch { /* non-fatal */ }
} }
@@ -86,10 +86,14 @@ function showUpdateBanner(r) {
// exactly as it did before the field existed. // exactly as it did before the field existed.
const channel = r.channel ? ` (${r.channel})` : ''; const channel = r.channel ? ` (${r.channel})` : '';
document.getElementById('update-text').textContent = document.getElementById('update-text').textContent =
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`; `Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion}). ` +
// Opening the signed XPI triggers Firefox's native install prompt. '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', () => { 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'); document.getElementById('update-banner').classList.remove('hidden');
} }
@@ -49,16 +49,20 @@
sometimes triggered nothing instead of the install dialog sometimes triggered nothing instead of the install dialog
(operator-flagged 2026-05-26). No `download` attribute — (operator-flagged 2026-05-26). No `download` attribute —
that would force a save dialog instead of install. --> that would force a save dialog instead of install. -->
<!-- The VERSIONED file, not the `latest` alias: a versioned URL can
only ever be this build's bytes, so a browser cache can't hand
back the previous build (operator-flagged 2026-09-25: a cached
alias reinstalled the old version and the update never took). -->
<v-btn <v-btn
v-if="isFirefox" v-if="isFirefox"
color="accent" variant="flat" rounded="pill" color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-firefox" prepend-icon="mdi-firefox"
:href="manifest.latest_url" :href="manifest.xpi_url"
>Install Firefox extension</v-btn> >Install Firefox extension</v-btn>
<v-btn <v-btn
variant="outlined" rounded="pill" variant="outlined" rounded="pill"
:href="manifest.latest_url" download :href="manifest.xpi_url" download
prepend-icon="mdi-download" prepend-icon="mdi-download"
>Download XPI</v-btn> >Download XPI</v-btn>
+22
View File
@@ -779,6 +779,28 @@ async def test_serve_extension_latest_returns_most_recent_xpi(
assert data == b"new" 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 @pytest.mark.asyncio
async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path): async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path):
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)