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
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:
+20
-2
@@ -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("/")
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user