Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e1382812 |
+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