Release: dev → main (first public release) #258

Merged
bvandeusen merged 94 commits from dev into main 2026-09-25 10:02:40 -04:00
5 changed files with 62 additions and 9 deletions
Showing only changes of commit 83e1382812 - Show all commits
+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
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("/")
+6 -1
View File
@@ -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,
};
}
+8 -4
View File
@@ -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');
}
@@ -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. -->
<!-- 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-if="isFirefox"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-firefox"
:href="manifest.latest_url"
:href="manifest.xpi_url"
>Install Firefox extension</v-btn>
<v-btn
variant="outlined" rounded="pill"
:href="manifest.latest_url" download
:href="manifest.xpi_url" download
prepend-icon="mdi-download"
>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"
@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)