Skip to content

Commit 6423fdf

Browse files
ralyodioclaude
andauthored
fix(store): stamp our update_url into the .crx, so installs can update (#65)
* fix(release): drop extension test files from the Windows zip too #62 pruned them from build-release.sh, which covers linux/macos/deb/rpm/ AppImage. The Windows job stages separately with Copy-Item -Recurse and kept shipping them: v3.8.9's tronbrowser-win-x64.zip contains moshpit.test.js (12077B) and moshpit-drift.test.js (4106B). Mirror the prune in the pwsh path so every platform matches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(store): stamp our update_url into the .crx, so installs can update Chromium polls the update_url baked into the installed manifest — it never consults the feed the store advertises on the listing page. Most publishers omit it (it points at us, and they write their manifest long before they list here), so a store install pinned itself to whatever version it was installed at and never moved again, while the store served a correct updates.xml that nothing ever asked for. CoinPay Wallet is the live case: the store, its gupdate feed and the packed .crx are all at 0.9.2, but the shipped manifest has no update_url, so every existing install sits on the version it was first given. The .crx is packed on demand and we already know the feed URL, so set it there, before signing (the CRX3 signature covers the zip bytes). Overwriting a publisher's own update_url is deliberate: the id comes from the store's signing key, and only our feed can serve an update Chromium accepts for it. Verified against the real published 0.9.2 bundle: all 13 files stay byte-identical, only the empty directory entries drop (Chromium recreates those from the paths), and the resulting .crx carries the feed URL. Note this cannot rescue already-installed copies — they have no update_url to poll, so they need one manual reinstall to get onto the channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c97f92d commit 6423fdf

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

services/api/src/store/crx.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import { zipSync, strToU8 } from 'fflate';
3-
import { crxToZip, extractListingFromCrx } from './crx.js';
2+
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
3+
import { crxToZip, extractListingFromCrx, stampUpdateUrl } from './crx.js';
44

55
// Build a minimal CRX3 (Cr24 + fake header + zip) for testing.
66
function makeCrx(files: Record<string, Uint8Array>, headerLen = 8): Uint8Array {
@@ -50,3 +50,44 @@ describe('crx ingest', () => {
5050
expect(() => extractListingFromCrx(crx)).toThrow(/MV3-only/);
5151
});
5252
});
53+
54+
describe('stampUpdateUrl', () => {
55+
const FEED = 'https://tronbrowser.dev/api/store/updates.xml?id=abc';
56+
const bundle = (manifest: object, extra: Record<string, Uint8Array> = {}) =>
57+
zipSync({ 'manifest.json': strToU8(JSON.stringify(manifest)), ...extra });
58+
const manifestOf = (zip: Uint8Array) =>
59+
JSON.parse(strFromU8(unzipSync(zip)['manifest.json']));
60+
61+
it('adds update_url to a manifest that has none', () => {
62+
// The CoinPay Wallet case: a valid MV3 bundle that never opted into the
63+
// store's feed, so the install could never move off its first version.
64+
const out = stampUpdateUrl(bundle({ manifest_version: 3, name: 'CoinPay Wallet', version: '0.9.2' }), FEED);
65+
expect(manifestOf(out).update_url).toBe(FEED);
66+
});
67+
68+
it('overwrites a publisher update_url that points elsewhere', () => {
69+
// Only our feed can serve an update for an id derived from our signing key.
70+
const out = stampUpdateUrl(bundle({ manifest_version: 3, name: 'x', version: '1', update_url: 'https://clients2.google.com/service/update2/crx' }), FEED);
71+
expect(manifestOf(out).update_url).toBe(FEED);
72+
});
73+
74+
it('keeps every other file and manifest field intact', () => {
75+
const out = stampUpdateUrl(
76+
bundle({ manifest_version: 3, name: 'x', version: '1', permissions: ['storage'] }, { 'sw.js': strToU8('console.log(1)') }),
77+
FEED,
78+
);
79+
const files = unzipSync(out);
80+
expect(strFromU8(files['sw.js'])).toBe('console.log(1)');
81+
expect(manifestOf(out).permissions).toEqual(['storage']);
82+
expect(manifestOf(out).version).toBe('1');
83+
});
84+
85+
it('returns the original bytes when the feed is already ours', () => {
86+
const zip = bundle({ manifest_version: 3, name: 'x', version: '1', update_url: FEED });
87+
expect(stampUpdateUrl(zip, FEED)).toBe(zip);
88+
});
89+
90+
it('rejects a bundle with no manifest', () => {
91+
expect(() => stampUpdateUrl(zipSync({ 'sw.js': strToU8('x') }), FEED)).toThrow(/no manifest\.json/);
92+
});
93+
});

services/api/src/store/crx.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// extension's own manifest.json + icons — so publishers don't fill out forms.
44
//
55
// Pure parsing (fetch is separate + guarded) so it's unit-testable.
6-
import { unzipSync, strFromU8 } from 'fflate';
6+
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
77

88
export interface IngestedListing {
99
name: string;
@@ -59,6 +59,43 @@ export function artifactToZip(buf: Uint8Array): Uint8Array {
5959
throw new Error('not a .crx or .zip bundle');
6060
}
6161

62+
/**
63+
* Stamp the store's gupdate feed into manifest.json before the zip is signed.
64+
*
65+
* Chromium only polls the `update_url` baked into the *installed* manifest — it
66+
* never consults the feed the store advertises on the listing page. So a
67+
* publisher who omits `update_url` (most of them: it points at us, and they
68+
* write their manifest long before they list here) ships an extension that
69+
* pins itself to whatever version was installed and never moves again. The
70+
* store then serves a perfectly correct updates.xml that nothing ever asks for.
71+
*
72+
* We pack the .crx on the fly and already know the feed URL, so we set it here.
73+
* Overwriting any existing value is deliberate: the id of this .crx comes from
74+
* the store's signing key, and only the store's feed can serve an update
75+
* Chromium will accept for that id — a publisher's own update_url would point
76+
* at a different id and silently do nothing.
77+
*
78+
* Must run before packCrx: the CRX3 signature covers these exact zip bytes.
79+
*
80+
* Re-zipping keeps every file byte-identical but drops the archive's empty
81+
* directory entries; Chromium recreates those from the file paths.
82+
*/
83+
export function stampUpdateUrl(zip: Uint8Array, updateUrl: string): Uint8Array {
84+
const files = unzipSync(zip, { filter: (f) => !f.name.endsWith('/') });
85+
const manifestBytes = files['manifest.json'];
86+
if (!manifestBytes) throw new Error('bundle has no manifest.json');
87+
let manifest: any;
88+
try {
89+
manifest = JSON.parse(strFromU8(manifestBytes));
90+
} catch (e: any) {
91+
throw new Error(`manifest.json is not valid JSON: ${e.message}`);
92+
}
93+
if (manifest.update_url === updateUrl) return zip; // already ours — don't re-zip
94+
manifest.update_url = updateUrl;
95+
files['manifest.json'] = strToU8(`${JSON.stringify(manifest, null, 2)}\n`);
96+
return zipSync(files);
97+
}
98+
6299
function iconToDataUri(path: string, bytes: Uint8Array): string | null {
63100
if (!bytes || bytes.length === 0 || bytes.length > MAX_ICON_BYTES) return null;
64101
const ext = path.toLowerCase().split('.').pop() || '';

services/api/src/store/routes.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from './payments.js';
2525
import { enqueueScan } from './vu1nz.js';
2626
import { mirrorListing } from './mirror.js';
27-
import { fetchArtifact, artifactToZip, extractListingFromCrx } from './crx.js';
27+
import { fetchArtifact, artifactToZip, extractListingFromCrx, stampUpdateUrl } from './crx.js';
2828
import { scanArtifact, type ExtensionScanResult } from './scanner.js';
2929
import { generateSigningKey, encryptPrivateKey, decryptPrivateKey, packCrx } from './signing.js';
3030
import { createScan, updateScan } from './db.js';
@@ -220,7 +220,11 @@ store.get('/extensions/:slug/download.crx', async (c) => {
220220

221221
try {
222222
const buf = await fetchArtifact(url);
223-
const crx = packCrx(artifactToZip(buf), decryptPrivateKey(key.private_key_enc), Buffer.from(key.public_key_der, 'base64'));
223+
// Stamp our gupdate feed in before signing, or the install pins itself to
224+
// this version forever: Chromium polls the manifest's own update_url and
225+
// most publishers never set one. See stampUpdateUrl.
226+
const zip = stampUpdateUrl(artifactToZip(buf), `${APP_URL}/api/store/updates.xml?id=${ext.id}`);
227+
const crx = packCrx(zip, decryptPrivateKey(key.private_key_enc), Buffer.from(key.public_key_der, 'base64'));
224228
c.header('content-type', 'application/x-chrome-extension');
225229
c.header('content-disposition', `attachment; filename="${ext.slug}-${ver!.version}.crx"`);
226230
// Hono wants an ArrayBuffer, not a Node Buffer view over a pooled one.

0 commit comments

Comments
 (0)