Skip to content

Commit 876afa9

Browse files
authored
Merge pull request #23 from levelcodeai/fix/updater-download-url
fix(updater): never open a raw .app.zip from the Download button (auto-update S3)
2 parents 7abd0f6 + 6a6f554 commit 876afa9

4 files changed

Lines changed: 66 additions & 13 deletions

File tree

docs/AUTO-UPDATE.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,40 @@ then lift the guard.
4444
3. **Arch mapping.** Feed targets are `darwin-arm64` and `darwin` (Intel). Map to the arm64 / x64 zips
4545
respectively — **never serve a cross-arch zip**.
4646
4. **Lift the guard.** Set `LEVELCODE_UPDATE_FEED_SIGNED=1` on Elastic Beanstalk — **only after 1–3**.
47-
5. **Notify-only Download button.** `extensions/levelcode-updater/extension.js:96` is
48-
`feed.url || product.downloadUrl || base`. Once `feed.url` is a raw `.zip`, that button would hand
49-
users a zip instead of the release page. Reorder to prefer `product.downloadUrl` / the release page.
47+
5. **Notify-only Download button.** `extensions/levelcode-updater/extension.js:96` *was*
48+
`feed.url || product.downloadUrl || base`. Once `feed.url` is a raw `.zip`, that button hands users a
49+
zip instead of a page. Now `U.downloadUrl(feed, product, base)``product.downloadUrl`
50+
`feed.releaseNotesUrl` → base, dropping `feed.url` from the human path entirely. See the Sequencing
51+
risk below: this gated the next *release*, not the flag flip.
5052

5153
## Slices
5254

53-
**S1 — publish the signed zip (client).** Add the `ditto` + `shasum` step to `make-dmg.sh` (or a
54-
`make-update-zip.sh`), update `docs/RELEASING.md`, and upload `LevelCode-<arch>.app.zip` with the dmg.
55-
*Ship this alone first — it is inert until the feed points at it.*
55+
**S1 — publish the signed zip (client). ✅ done.** Add the `ditto` step to `make-dmg.sh`, update
56+
`docs/RELEASING.md`, and upload `LevelCode-<arch>.app.zip` with the dmg.
57+
*Shipped alone first — it is inert until the feed points at it.*
5658

57-
**S2 — serve it (server).** Teach `EditorReleaseFeed` to pick the arch-matched asset + hash. Guard stays
58-
on, so behaviour is unchanged; assert the new shape in `spec/requests/api/updates_spec.rb`.
59+
**S2 — serve it (server). ✅ done.** Teach `EditorReleaseFeed` to pick the arch-matched asset + hash.
60+
Guard stays on, so behaviour is unchanged; the new shape is asserted in `spec/requests/api/updates_spec.rb`.
5961

60-
**S3 — extension URL fix.** Reorder the Download preference so it never opens a raw zip.
62+
**S3 — extension URL fix. ✅ done.** `U.downloadUrl()` in `extensions/levelcode-updater/update.js`, so
63+
the Download button can never open a raw zip.
6164

6265
**S4 — flip the flag + verify.** Set `LEVELCODE_UPDATE_FEED_SIGNED=1`, then run the end-to-end test below.
66+
*Blocked until a release actually carries the S1 zips* — every release cut before S1 resolves to
67+
`installable: false`, so flipping the flag against today's releases is inert (Squirrel still gets 204).
6368

6469
## Risks (the ones that actually bite)
6570

66-
- **Sequencing.** Flipping the flag before S1–S2 makes things *worse* — Squirrel would download a web
67-
page and fail loudly. S4 must be last.
71+
- **Sequencing.** Two *independent* constraints — the second is easy to miss:
72+
- Flipping the flag before S1–S2 makes things *worse*: Squirrel would download a web page and fail
73+
loudly. S4 must be last. This is now enforced in code, not just documented — the controller requires
74+
a resolved entry to be `installable`, so an early flip still serves 204.
75+
- **S3 gated the next RELEASE, not S4.** The notify-only extension is served a 200 *regardless* of
76+
`LEVELCODE_UPDATE_FEED_SIGNED` — see `updates_controller.rb`: `unless notify_only_client? ||
77+
(signed_feed? && rel[:installable])`. So the moment any release carries an `.app.zip`, `feed.url`
78+
becomes that zip for the extension too. Publishing an S1-asset release with S3 unshipped would have
79+
pointed every "Download" button at a raw zip, flag or no flag. Unlike the constraint above, nothing
80+
in the code would have stopped it — hence `U.downloadUrl()` and its regression test.
6881
- **Signing-identity continuity.** Squirrel.Mac refuses an update whose Developer ID doesn't match the
6982
running app. **Rotating or changing the signing cert breaks auto-update for every installed build**,
7083
with no in-app recovery — those users must re-download manually. Treat the identity as long-lived.

extensions/levelcode-updater/extension.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async function check(explicit) {
9393
actions.push('Later');
9494
const choice = await vscode.window.showInformationMessage(U.releaseLabel(feed) + ' is available.', ...actions);
9595
if (choice === 'Download') {
96-
const dl = feed.url || product.downloadUrl || base;
96+
const dl = U.downloadUrl(feed, product, base);
9797
try { await vscode.env.openExternal(vscode.Uri.parse(dl)); } catch { /* */ }
9898
} else if (choice === 'Release Notes') {
9999
const rn = feed.releaseNotesUrl || product.releaseNotesUrl;

extensions/levelcode-updater/test/update.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,26 @@ test('releaseLabel prefers productVersion', () => {
5858
assert.strictEqual(U.releaseLabel({ version: 'x' }), 'A new LevelCode build');
5959
});
6060

61+
// The regression this guards: before auto-update S2 the feed's `url` was a release PAGE, so opening it
62+
// was fine. S2 made it the arch-matched LevelCode-<arch>.app.zip — an install artifact for Squirrel.
63+
// A notify-only "Download" button must never open that; it would start a raw zip download.
64+
test('downloadUrl NEVER returns feed.url — not even when it is a signed .app.zip', () => {
65+
const feed = {
66+
version: 'abc', productVersion: '0.7.3',
67+
url: 'https://github.com/levelcodeai/levelcode/releases/download/v0.7.3/LevelCode-arm64.app.zip',
68+
releaseNotesUrl: 'https://github.com/levelcodeai/levelcode/releases/tag/v0.7.3'
69+
};
70+
const product = { downloadUrl: 'https://levelcode.ai/download' };
71+
assert.strictEqual(U.downloadUrl(feed, product, 'https://levelcode.ai'), 'https://levelcode.ai/download');
72+
// and with no product download funnel configured, it falls back to a PAGE, still never the zip
73+
assert.strictEqual(U.downloadUrl(feed, {}, 'https://levelcode.ai'), feed.releaseNotesUrl);
74+
assert.strictEqual(U.downloadUrl(feed, {}, 'https://levelcode.ai').endsWith('.zip'), false);
75+
});
76+
77+
test('downloadUrl degrades to the feed base, and tolerates null feed/product', () => {
78+
assert.strictEqual(U.downloadUrl({ url: 'https://x/a.zip' }, {}, 'https://levelcode.ai'), 'https://levelcode.ai');
79+
assert.strictEqual(U.downloadUrl(null, null, 'https://levelcode.ai'), 'https://levelcode.ai');
80+
assert.strictEqual(U.downloadUrl(null, null, undefined), '');
81+
});
82+
6183
console.log('\nupdate.js: ' + n + ' tests passed.');

extensions/levelcode-updater/update.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,22 @@ function releaseLabel(feed) {
4646
return feed.productVersion ? ('LevelCode ' + feed.productVersion) : 'A new LevelCode build';
4747
}
4848

49-
module.exports = { platformTarget, buildFeedUrl, parseFeed, isNewer, releaseLabel };
49+
/**
50+
* Where the "Download" button sends a human.
51+
*
52+
* Deliberately NOT `feed.url`. Since the signed-zip work (auto-update S2) that field is the
53+
* arch-matched `LevelCode-<arch>.app.zip` — an artifact for the editor's built-in Squirrel updater to
54+
* INSTALL, not a page to open. Opening it hands the user a raw zip download instead of a release page.
55+
* This extension only ever NOTIFIES, so it points exclusively at human-facing pages: the download
56+
* funnel first, then this release's notes, then the feed base as a last resort.
57+
*
58+
* Note the feed serves `url` unconditionally to this extension — the LEVELCODE_UPDATE_FEED_SIGNED
59+
* guard gates Squirrel, not the notify-only client — so this must not depend on that flag being off.
60+
* @param {any} feed @param {any} product @param {string} [base]
61+
*/
62+
function downloadUrl(feed, product, base) {
63+
const f = feed || {}, p = product || {};
64+
return p.downloadUrl || f.releaseNotesUrl || base || '';
65+
}
66+
67+
module.exports = { platformTarget, buildFeedUrl, parseFeed, isNewer, releaseLabel, downloadUrl };

0 commit comments

Comments
 (0)