feat: recognize .msix / .msixbundle assets and 'msix' package format - #26
feat: recognize .msix / .msixbundle assets and 'msix' package format#26jjeff wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds MSIX/MSIXBUNDLE support across file extension, package format, OS/architecture detection, and update feed behavior to better support Windows MSIX auto-updaters (Squirrel.Mac-shaped JSON).
Changes:
- Add
.msixand.msixbundleto supported extensions and Windows download extension resolution whenpkg=msix. - Add
msixas a recognizedPackageFormatand detect MSIX/MSIXBUNDLE filenames accordingly. - Extend OS/architecture detection to treat
.msixbundleas universal and.msix/.msixbundleas Windows; add/update unit tests and Pecans update route tests.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/unit/pecans.spec.ts | Adds tests asserting update JSON shape and URL behavior for MSIX/MSIXBUNDLE requests. |
| test/unit/SupportedFileExtension.spec.ts | Extends extension support tests and Windows download extension selection for pkg=msix. |
| test/unit/PackageFormat.spec.ts | Adds msix to formats and tests filename/query detection. |
| test/unit/OperatingSystem.spec.ts | Adds Windows detection tests for .msix and .msixbundle. |
| test/unit/Architecture.spec.ts | Adds architecture detection tests for .msix vs .msixbundle (universal). |
| src/utils/SupportedFileExtension.ts | Adds MSIX extensions and updates Windows download extension resolution for pkg=msix. |
| src/utils/PackageFormat.ts | Adds msix to PACKAGE_FORMATS and detects .msix/.msixbundle filenames. |
| src/utils/OperatingSystem.ts | Recognizes .msix and .msixbundle as Windows. |
| src/utils/Architecture.ts | Treats .msixbundle as universal architecture. |
- SupportedFileExtension.ts: revert the misleading comment claim about special-casing .msixbundle. path.extname() already returns ".msixbundle" correctly for files with that extension; only .tar.gz needs the explicit endsWith() check because extname returns ".gz" for files ending in ".tar.gz". - pecans.spec.ts: add channel:"stable" to the msixbundle test for parameter-shape consistency with the preceding msix test, so both cases exercise the same route contract.
Electron 41+'s autoUpdater has native MSIX support and consumes the
existing Squirrel.Mac-shaped JSON response format from /update/:platform/:version.
For Pecans to act as the update server for an MSIX-packaged Electron app,
it only needs to recognize the new asset extensions and surface them as
download candidates when callers request the 'msix' package format.
- Add .msix and .msixbundle to SUPPORTED_FILE_EXTENSIONS.
- Add 'msix' to PACKAGE_FORMATS; filenameToPackageFormat detects both
.msix and .msixbundle.
- filenameToOperatingSystem maps .msix / .msixbundle to 'windows'.
- filenameToArchitecture treats .msixbundle as 'universal' (multi-arch
bundle); single-arch .msix continues to derive arch from filename.
- getDownloadExtensionsByOs('windows', 'msix') returns
['.msixbundle', '.msix'], preferring the bundle when both are present.
The existing /update/:platform/:version JSON endpoint already emits the
{url, name, notes, pub_date} shape Electron's MSIX updater expects; with
the recognition above, callers pass ?filetype=msix (or msixbundle) and
the resolved download URL routes through /download/version/.../?filetype=msix
to the appropriate asset.
Tests cover extension/package/OS/arch detection and the update endpoint's
behavior for Windows MSIX clients.
- SupportedFileExtension.ts: revert the misleading comment claim about special-casing .msixbundle. path.extname() already returns ".msixbundle" correctly for files with that extension; only .tar.gz needs the explicit endsWith() check because extname returns ".gz" for files ending in ".tar.gz". - pecans.spec.ts: add channel:"stable" to the msixbundle test for parameter-shape consistency with the preceding msix test, so both cases exercise the same route contract.
…gestion filenameToPlatform built keys like WINDOWS_MSIX_UNIVERSAL that don't exist in the platforms record, so the GitHub backend dropped every .msix and .msixbundle asset and ?filetype=msix silently resolved to the .exe. - add windows_universal to PLATFORMS; .msixbundle assets now type as windows_universal, single-arch .msix as windows_32/64 - exclude msix from platform id composition (windows ids don't encode pkg; msix assets resolve by filetype instead) - resolveReleaseAssetForVersion matches windows_universal for arch-specific windows requests, but only when an msix filetype is explicitly wanted so default flows keep serving arch-specific .exe installers - versions.filter counts windows_universal assets toward availability for arch-specific windows queries so bundle-only releases don't 404 - cover the ingestion seam (normalizeRelease/filenameToPlatform) and resolution behavior with tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018YjpR5uuatwzQvNbpDo8n4
dopry
left a comment
There was a problem hiding this comment.
Thanks for this — the direction is right, the utility-layer changes are clean, and the added unit tests are well written. Unfortunately there's an integration gap that keeps the feature from working end-to-end, found by executing this branch rather than just reading it:
filenameToPlatform returns undefined for every MSIX asset, because the composed key (WINDOWS_MSIX_64 / WINDOWS_MSIX_UNIVERSAL) doesn't exist in the platforms record. The GitHub backend drops any asset whose platform resolves to null/undefined, so no .msix/.msixbundle ever becomes a release asset. Net effect: ?filetype=msix silently serves the .exe when one exists (an MSIX updater can't apply that), or 404s on an MSIX-only release. Details in the inline comments, along with a second issue: windows_universal assets aren't reachable from windows_64 requests in resolveReleaseAssetForVersion/versions.filter even after the platform map is fixed.
The existing 637 tests do pass (confirmed locally), but the new tests all stop short of the ingestion→resolution seam where this lives — suggestions for seam tests are inline.
A ready-made fix is available: branch claude/pecans-pr-26-review-249u6k on this repo contains your two commits plus a fix commit addressing all of the above, with tests at the seam (655 unit tests pass, tsc clean). It's a fast-forward on top of your head, so you can pull it straight into this PR:
git checkout feat/msix-support
git pull https://github.com/dopry/pecans claude/pecans-pr-26-review-249u6k
git push
On downstream compatibility (important for existing pecans consumers): with the fix applied, default windows downloads still resolve [".exe"] unless pkg=msix/filetype=msix is explicitly requested, the Squirrel.Windows RELEASES/.nupkg path is untouched, and there's a regression test pinning that the .exe still wins default resolution when MSIX assets are published alongside it. The PackageFormat/SupportedFileExtension unions widen, which only affects TypeScript consumers doing exhaustive switches — semver-minor.
Generated by Claude Code
| const name = filename.toLowerCase(); | ||
| if (name.endsWith(".deb")) return "deb"; | ||
| if (name.endsWith(".rpm")) return "rpm"; | ||
| if (name.endsWith(".msix") || name.endsWith(".msixbundle")) return "msix"; |
There was a problem hiding this comment.
Blocker: returning "msix" here breaks filenameToPlatform (src/utils/platforms.ts), which composes the platform key as os_pkg_arch — for MSIX assets that produces WINDOWS_MSIX_64 / WINDOWS_MSIX_UNIVERSAL, neither of which exists in the platforms record, so it returns undefined.
The GitHub backend filters release assets with filenameToPlatform(asset.name) != null (src/backends/github.ts:229-236), so every .msix/.msixbundle asset is silently dropped at ingestion and the feature doesn't work end-to-end. Verified by running the code on this branch:
filenameToPlatform("Visibox-5.0.13.msixbundle") → undefined
filenameToPlatform("Visibox_5.0.13.0_x64.msix") → undefined
Worse, because resolveReleaseAssetForVersion keeps all supported extensions as fallbacks, ?filetype=msix then silently serves the .exe when one exists — an MSIX updater receives an installer it can't apply — or 404s when the release only has MSIX assets.
Fix: keep msix out of the platform-id composition in filenameToPlatform (windows platform ids don't encode pkg; msix resolves by filetype) and add windows_universal to PLATFORMS so .msixbundle gets a valid type.
Generated by Claude Code
| const name = filename.toLowerCase(); | ||
| if (name == "releases") return "universal"; | ||
| // .msixbundle is a multi-architecture bundle by definition | ||
| if (name.endsWith(".msixbundle")) return "universal"; |
There was a problem hiding this comment.
Mapping .msixbundle → universal is right in principle, but nothing downstream can match it for windows clients. resolveReleaseAssetForVersion filters with asset.type.startsWith(platform) and only adds a universal fallback for OSX (src/utils/resolveForVersion.ts:26-28); versions.ts:53-56 has the same osx-only special case. So a client on windows_64 — the platform your own handleUpdateOSX tests use, and what the win32-x64 legacy mapping produces — would never resolve a bundle even once the platform-map issue is fixed.
Two additions are needed:
resolveReleaseAssetForVersion: also matchwindows_universalfor arch-specific windows requests — but gate it on the wanted filetype being.msix/.msixbundle, otherwise a published bundle would start outranking the.exefor existing Squirrel clients (the type-length sort prefers longer types).versions.filter: countwindows_universalassets toward availability forwindows_*queries, so a bundle-only release doesn't 404 the update endpoint.
Related edge worth a comment or test: arm64-only .msix files (App_arm64.msix) resolve to arch arm64, which isn't a supported windows arch (getSupportedArchByOs), so they're dropped — arm64 MSIX clients need the .msixbundle path.
Generated by Claude Code
| ); | ||
| }); | ||
|
|
||
| it("should produce Squirrel.Mac-shaped JSON for Windows MSIX clients", async () => { |
There was a problem hiding this comment.
These two tests mock pecans.versions.filter and assert only the shape of the JSON/URL string, so they pass even though MSIX assets never survive backend ingestion — the bug lives in the seam between normalizeRelease/filenameToPlatform and resolveReleaseAssetForVersion, which nothing here crosses.
Suggest adding:
- a
normalizeReleasetest (ingithub.spec.ts) asserting a release with.exe+.msix+.msixbundleassets keeps all three, with the expectedtype/arch/pkg; resolveReleaseAssetForVersiontests:filetype=msixpicks the.msix/bundle over the.exe, and — the downstream-compat guarantee — a default request (no filetype) still picks the.exewhen msix assets are published alongside it.
Generated by Claude Code
|
Closing as superseded by #46. Since this branch was opened, #46 reimplements the feature on the new architecture with the same external contract ( Generated by Claude Code |
…46) * feat: recognize .msix / .msixbundle assets and 'msix' package format Reimplementation of #26 on the unified ReleaseService resolution pipeline (msix as a first-class package format on the discrete {os, arch, pkg} model): - SUPPORTED_FILE_EXTENSIONS gains .msix / .msixbundle; getDownloadExtensionsByOs("windows", "msix") returns [".msixbundle", ".msix"], preferring the multi-arch bundle. - PACKAGE_FORMATS gains "msix"; filenameToPackageFormat detects both extensions; new filetypeToPackageFormat maps ?filetype=msix|msixbundle to the msix pkg at the HTTP edge (other filetypes keep their platform-default semantics). - filenameToOperatingSystem maps .msix / .msixbundle to windows; filenameToArchitecture treats .msixbundle as universal (multi-arch by definition), single-arch .msix derives arch from the filename suffix. - New composite platform ids windows_msix{,_32,_64,_universal} so msix assets survive ingestion; arm64 .msix drops like any arm64 windows asset. - assetMatchesPlatform: a universal msix asset satisfies any windows arch when msix is explicitly requested; default windows flows (pkg "default") never widen to msix, so Squirrel clients keep resolving the .exe when msix assets are published alongside it. - /update/:platform/:version?filetype=msix serves the existing Squirrel.Mac-shaped JSON that Electron 41+'s MSIX autoUpdater consumes; /download/...?filetype=msix and /dl/windows/:arch?pkg=msix route to the assets. The Squirrel.Windows RELEASES/.nupkg path is untouched. Co-Authored-By: Jeff Robbins <321284+jjeff@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019F5tixJXBm2R1FAjAfH1se * style: prettier formatting in msix integration spec Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019F5tixJXBm2R1FAjAfH1se * fix: canonicalize filetype casing in the update feed url filetypeToPackageFormat accepts ?filetype=MSIX case-insensitively, but the handler embedded the caller's casing into the generated download url, which the download route's case-sensitive filetype validation then rejects with a 400. Lowercase the filetype before both the pkg mapping and the feed url. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019F5tixJXBm2R1FAjAfH1se * fix: don't let ?filetype constrain exact-filename downloads The filetype->pkg implication belongs to platform-based resolution; an explicit :filename already identifies the asset and its package format, so a stray ?filetype=msix on an .exe filename request must not narrow release resolution (previously a 404 when no msix assets exist). Also reword the filetypeToPackageFormat doc comment: internal windows_msix* composite ids exist for ingestion, they're just not part of the legacy request vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019F5tixJXBm2R1FAjAfH1se --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jeff Robbins <321284+jjeff@users.noreply.github.com>
# [2.0.0-next.18](v2.0.0-next.17...v2.0.0-next.18) (2026-07-21) * feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) ([#44](#44)) ([8a2f06a](8a2f06a)) ### Bug Fixes * **github:** default octokit to native fetch to prevent empty release lists ([#28](#28)) ([0f6eac6](0f6eac6)) * honor route params in downloads, dead code removal, small fixes (Phase 2) ([#31](#31)) ([9fd8f79](9fd8f79)) * semantic-release trusted publishing ([4f1ee88](4f1ee88)) * semantic-release trusted publishing ([#53](#53)) ([3d4d4ce](3d4d4ce)) * working generic refresh webhook; document raw as the backend-private asset slot (Phase 7 PR A) ([#43](#43)) ([23d1d9f](23d1d9f)) ### chore * esm only ([#24](#24)) ([1b8a546](1b8a546)) ### Features * dependency modernization — Express 5, octokit 22, remove UA autodetection (Phase 5) ([#41](#41)) ([8ea329a](8ea329a)) * modernize packaging and dev tooling (Phase 3) ([#32](#32)) ([3345ed7](3345ed7)) * recognize .msix / .msixbundle assets and 'msix' package format ([#46](#46)) ([4f848b9](4f848b9)), closes [#26](#26) * typed HTTP errors with router-scoped error handling (Phase 6) ([#42](#42)) ([e3b7b54](e3b7b54)) ### BREAKING CHANGES * @dopry/pecans is now ESM-only. require('@dopry/pecans') is no longer supported; use import (Node >= 22.12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zXcPSv6FFTPgD59T4KudM * refactor: import model types with import type in runtime modules Follows up on Copilot review: PecansRelease/PecansReleases (and other names used only in type positions) are classes, so tsc accepts plain imports, but with verbatimModuleSyntax they would stay in the emitted JS as runtime imports. Convert the type-only usages to import type to keep the runtime module graph minimal and cycle-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zXcPSv6FFTPgD59T4KudM * Pecans no longer exposes a versions property. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * feat!: remove the Versions and resolveReleaseAssetForVersion adapters The deprecation shims this PR introduced are dropped instead of carried to 3.0: route handlers and consumers resolve through ReleaseService / resolveAssetForRelease directly. The table-driven specs that pinned the legacy composite-id resolution semantics are migrated onto the pipeline (via platformToQuery) so the behavioral pins survive the adapter removal; unique Versions coverage moved into service.spec. Pre-existing deprecations (GitHubBackend, PecansSettings.timeout, PecansReleaseDTO.channel) keep their 3.0 schedule. * Versions, VersionFilterOpts, PlatformQuery, and resolveReleaseAssetForVersion are no longer exported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * getArchFromUserAgent, getOsFromUserAgent, and getPlatformFromUserAgent now take the internal UserAgentDetails type instead of express-useragent's Details, and getArchFromUserAgent defaults Windows and Linux to '64' (32-bit desktops are effectively extinct; the function is not used internally). pecans consumed exactly four booleans from the unmaintained express-useragent package; src/utils/userAgent.ts derives them from the User-Agent header directly, with mobile exclusions the old library handled via separate flags (iOS UAs contain 'like Mac OS X', Android UAs contain 'Linux'). The middleware attaches the same req.useragent shape. Unit specs cover the parser; the Phase 1 UA-driven download contract tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: validate update-route params through getStringParam consistently Review feedback: handleUpdateOSX truthiness-checked req.params directly but read values through getStringParam, and handleUpdateWin had no version guard at all; a missing tag would have produced a '>=undefined' range. Validate once through the helper and reuse the validated values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: exclude Macintosh+Mobile webview UAs from macOS detection Review feedback (partial): a Mobile token alongside Macintosh indicates an iPad-class webview masquerading as a Mac; genuine macOS browsers never send it. Fixture + test added. Note true iPadOS desktop-mode UAs are byte-identical to Mac Safari and undetectable by any parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: short-circuit dlfilename when the filename param is absent Review feedback: an undefined filename passed into queryReleases matches every release (the predicate treats undefined as no-filter), which would serve an arbitrary asset instead of a 404. Unreachable via the current route but guarded for consistency with the update handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * feat: remove user-agent platform autodetection * selecting a platform is now the client's responsibility. GET / is no longer a download route, and the platform segment is required on /download, /download/version/:tag, and /download/channel/:channel (a missing platform returns 400). The user-agent parser, its middleware, and the getPlatformFromUserAgent / getArchFromUserAgent / getOsFromUserAgent helpers are removed. Autodetection only ever served bare browser links - Squirrel update clients and /dl/* always send explicit platforms - and reliable device detection is better handled client-side where UA Client Hints are available. Reverting this commit restores the feature wholesale if anyone misses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: remove imports orphaned by the autodetection removal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: validate tag ranges early in validateReqQueryTag Review feedback: validRange's result was discarded, so invalid tags only failed deep in release matching with a generic 'Invalid Range Specified' error. Invalid ranges now throw UnsupportedTagError at the parameter boundary ('latest' stays allowed), and the error message no longer says 'channel' for tags (copy-paste from UnsupportedChannelError). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * build output moves from dist/cjs + dist/mjs to a tsup bundle (dist/index.js CJS, dist/index.mjs ESM). Deep imports into dist paths no longer resolve; all models (PecansRelease, PecansAsset, PecansReleases, ...) are now exported from the package root instead. - replace the dual-tsc + fixup.sh build with tsup (CJS + ESM + d.ts + sourcemaps, node22 target); consolidate four tsconfigs into one typecheck-only tsconfig.json - add a proper exports map with types for both module systems; verified with publint and arethetypeswrong (all green: node10/node16/bundler) - declare debug and qs as real dependencies - both are imported directly but were only present transitively via express, which broke the ESM bundle (inlined CJS require calls) - guard the run-directly check with typeof require so the ESM build is importable; node dist/index.js still starts the server - ts-node out of runtime dependencies; dev now runs tsx watch; start runs the compiled dist; drop nodemon - ESLint 9 flat config + prettier (replaces the stale mocha-era .eslintrc); fix the 19 findings it surfaced (unused imports/vars, case-block declarations, error causes, no-useless-assignment) - add explicit @types/node; add lint/format/typecheck scripts - package.json: type commonjs, sideEffects false, canonical repo url Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: rank .tar.gz assets by their full extension in resolveForVersion path.extname reports '.gz' for .tar.gz filenames, so the sort fallback ranked them at prefs.indexOf(-1) - ahead of every genuine preference - whenever .tgz and .tar.gz assets coexisted. Use getSupportedExt, which handles the double extension, matching the Phase 2 fix to PecansAsset.satisfiesExtensions. Regression test added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: throw Error from configure(), correct Listening typo Review feedback: the default switch case in configure() threw a raw string (no stack trace); the startup log said 'Lisening'. The test that pinned the typo is updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM * fix: throw on invalid os in getDownloadExtensionsByOs Review feedback: the switch had no default, so an invalid OperatingSystem cast in at runtime silently returned undefined against the declared SupportedFileExtension[] return type. Fail loudly instead; edge-case test updated to pin the throw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
Summary
Adds MSIX support to Pecans so it can act as the update server for an Electron 41+ app packaged as MSIX. Electron 41's
autoUpdaterconsumes the existing Squirrel.Mac-shaped JSON the/update/:platform/:versionendpoint already emits — Pecans only needs to recognize.msix/.msixbundleas Windows assets and route to them when callers request themsixpackage format.Changes
SUPPORTED_FILE_EXTENSIONSwith.msixand.msixbundle.PACKAGE_FORMATSwithmsix;filenameToPackageFormatdetects both extensions.filenameToOperatingSystemmaps.msix/.msixbundle→windows.filenameToArchitecturetreats.msixbundleasuniversal(it's a multi-arch bundle by definition); single-arch.msixcontinues to derive arch from filename suffix (_x64,_arm64,_x86).getDownloadExtensionsByOs('windows', 'msix')returns['.msixbundle', '.msix'], preferring the bundle when both are published.The existing
/update/:platform/:versionJSON endpoint already emits the{url, name, notes, pub_date}shape Electron's MSIX updater consumes. Callers (Electron'sautoUpdater.setFeedURL) just pass?filetype=msixor?filetype=msixbundleand the resolved download URL routes through/download/version/.../?filetype=msixto the matching asset.Test plan
handleUpdateOSXproduces a Squirrel.Mac-shaped JSON response with a.msix/.msixbundleURL when called with the appropriate filetypetsc --noEmitcleanNotes
This is the server-side half of MSIX support for spaceagetv/missioncontrol (Visibox). Reference: Electron 41 release notes and electron/electron#49230.
🤖 Generated with Claude Code