fix(build): WiX MSI version mapping and album dynamic import (#1278)

This commit is contained in:
cucadmuh
2026-07-12 22:45:22 +03:00
committed by GitHub
parent 54bc9814f1
commit 6e448dcc3c
12 changed files with 270 additions and 5 deletions
+7
View File
@@ -277,6 +277,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Restores the lightweight cpal default-output path on Windows so startup no longer blocks on full device enumeration.
* Audio output devices on Windows and macOS now use stable backend IDs (`Wasapi:…`) with clearer labels; duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
### Windows — MSI bundle on dev and RC versions
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1278](https://github.com/Psychotoxical/psysonic/pull/1278)**
* Windows `.msi` builds no longer fail on channel versions like `1.50.0-dev` — WiX requires a numeric fourth version field, so the bundler maps `-dev` / `-rc.N` to numeric semver in `bundle.windows.wix.version` while Settings → About still shows the real package version.
* Release builds no longer warn that the album feature barrel defeats a lazy import in the new-albums easter egg (direct import of the export helper).
## [1.49.0] - 2026-06-29
+2
View File
@@ -27,6 +27,8 @@ Version is authoritative in `package.json` and `package-lock.json`. Promotion wo
- `next` version format: `X.Y.Z-rc.N`
- `release` version format: `X.Y.Z`
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
Rules:
1. Never edit versions manually in random commits.
+2 -2
View File
@@ -10,14 +10,14 @@
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
"build:verify": "npm run build && npm run check:boot-chunks",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"test:watch": "vitest",
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
@@ -47,3 +47,8 @@ if (updatedLock !== lock) {
} else {
console.log(`Cargo.lock workspace crates already at ${version}`);
}
require('child_process').execSync('node scripts/sync-wix-bundle-version.mjs', {
cwd: root,
stdio: 'inherit',
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Integration tests for promotion/version sync: npm version → sync-tauri → sync-wix.
* Simulates tauri.conf.json state after the full pipeline (no file I/O).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
/** Mirrors sync-tauri + sync-wix effects on tauri.conf.json. */
function confAfterVersionSync(packageVersion, priorConf) {
const conf = structuredClone(priorConf);
conf.version = packageVersion;
conf.bundle ??= {};
conf.bundle.windows ??= {};
const override = wixVersionOverrideForPackageVersion(packageVersion);
conf.bundle.windows.wix = { ...(conf.bundle.windows.wix ?? {}), version: override };
return conf;
}
const baseConf = {
version: '1.49.0-dev',
bundle: {
windows: {
nsis: { installMode: 'currentUser' },
wix: { version: '1.49.0.1' },
},
},
};
describe('version promotion pipeline (sync-tauri + sync-wix)', () => {
it('main → next: dev to first RC increases WiX build', () => {
const conf = confAfterVersionSync('1.50.0-rc.1', baseConf);
assert.equal(conf.version, '1.50.0-rc.1');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10001');
assert.ok(
wixMappedBuildNumber('1.50.0-rc.1') > wixMappedBuildNumber('1.50.0-dev'),
);
});
it('next RC bump: rc.1 to rc.2', () => {
const from = confAfterVersionSync('1.50.0-rc.1', baseConf);
const conf = confAfterVersionSync('1.50.0-rc.2', from);
assert.equal(conf.version, '1.50.0-rc.2');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10002');
});
it('next → release: RC to stable increases WiX build', () => {
const from = confAfterVersionSync('1.50.0-rc.3', baseConf);
const conf = confAfterVersionSync('1.50.0', from);
assert.equal(conf.version, '1.50.0');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.65534');
assert.ok(wixMappedBuildNumber('1.50.0') > wixMappedBuildNumber('1.50.0-rc.3'));
});
it('post-release: next minor dev resets build band on new line', () => {
const from = confAfterVersionSync('1.50.0', baseConf);
const conf = confAfterVersionSync('1.51.0-dev', from);
assert.equal(conf.version, '1.51.0-dev');
assert.equal(conf.bundle.windows.wix.version, '1.51.0.1');
});
it('stable release sets highest build in line', () => {
const conf = confAfterVersionSync('2.0.0', {
version: '2.0.0-rc.1',
bundle: { windows: { wix: { version: '2.0.0.10001' } } },
});
assert.equal(conf.version, '2.0.0');
assert.equal(conf.bundle.windows.wix.version, '2.0.0.65534');
});
});
describe('sync-tauri-version-from-package.js invokes sync-wix', () => {
it('calls sync-wix-bundle-version.mjs after updating conf.version', () => {
const source = readFileSync(new URL('./sync-tauri-version-from-package.js', import.meta.url), 'utf8');
assert.match(source, /sync-wix-bundle-version\.mjs/);
const wixCall = source.indexOf('sync-wix-bundle-version');
const versionWrite = source.indexOf('conf.version = version');
assert.ok(wixCall > versionWrite, 'sync-wix must run after conf.version is set');
});
});
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Write bundle.windows.wix.version in tauri.conf.json from package.json.
* Keeps the top-level app version unchanged (About, updater metadata, filenames).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { wixVersionOverrideForPackageVersion } from './wix-bundle-version.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
if (!version || typeof version !== 'string') {
console.error('package.json version missing');
process.exit(1);
}
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.bundle ??= {};
conf.bundle.windows ??= {};
conf.bundle.windows.wix ??= {};
const wixVersion = wixVersionOverrideForPackageVersion(version);
conf.bundle.windows.wix.version = wixVersion;
console.log(`tauri.conf wix.version -> ${wixVersion} (package ${version})`);
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
+96
View File
@@ -0,0 +1,96 @@
/**
* Map package.json semver to a monotonic WiX/MSI ProductVersion for Tauri.
*
* `bundle.windows.wix.version` must be `major.minor.patch.build` (four integers
* ≤ 65535). Alphabetic pre-releases cannot be used directly. NSIS accepts full
* semver without this mapping.
*
* Build bands (monotonic within X.Y.Z so in-place MSI upgrades work across
* dev → rc → stable):
* dev → .1
* rc.N → .10000 + N
* stable → .65534
*
* Display / About still use the real package.json version.
*/
/** @type {const} */
export const WIX_BUILD = {
DEV: 1,
RC_BASE: 10_000,
STABLE: 65_534,
};
const MAX_WIX_FIELD = 65_535;
/** @param {number} build */
function assertBuildField(build, label) {
if (!Number.isInteger(build) || build < 0 || build > MAX_WIX_FIELD) {
throw new Error(`WiX build field out of range for ${label}: ${build}`);
}
}
/**
* WiX dot version for bundle.windows.wix.version (always four parts for channels
* we ship).
* @param {string} version
*/
export function wixVersionOverrideForPackageVersion(version) {
const trimmed = version.trim();
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(\d+))?$/);
if (!match) {
throw new Error(`Invalid semver for WiX mapping: ${trimmed}`);
}
const major = match[1];
const minor = match[2];
const patch = match[3];
const pre = match[4];
const buildPart = match[5];
const base = `${major}.${minor}.${patch}`;
if (buildPart !== undefined) {
throw new Error(
`Version "${trimmed}" has +build metadata — map manually or drop build for WiX`,
);
}
if (pre === undefined) {
return `${base}.${WIX_BUILD.STABLE}`;
}
if (pre === 'dev') {
return `${base}.${WIX_BUILD.DEV}`;
}
const rc = pre.match(/^rc\.(\d+)$/);
if (rc) {
const n = Number(rc[1]);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`WiX rc index must be ≥ 1 (got rc.${rc[1]})`);
}
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `rc.${n}`);
return `${base}.${build}`;
}
if (/^\d+$/.test(pre)) {
const n = Number(pre);
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `pre ${pre}`);
return `${base}.${build}`;
}
throw new Error(
`Version "${trimmed}" has non-numeric pre-release "${pre}" — MSI/WiX cannot bundle it. ` +
'Use NSIS (`--bundles nsis`) or extend wix-bundle-version.mjs.',
);
}
/** Numeric build field from mapped WiX version (for monotonicity tests). */
export function wixMappedBuildNumber(packageVersion) {
const wix = wixVersionOverrideForPackageVersion(packageVersion);
const build = Number(wix.split('.')[3]);
assertBuildField(build, packageVersion);
return build;
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
WIX_BUILD,
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
describe('wixVersionOverrideForPackageVersion', () => {
it('maps -dev to lowest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-dev'), '1.50.0.1');
});
it('maps -rc.N to RC base + N', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-rc.3'), '1.50.0.10003');
});
it('maps stable to highest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0'), '1.50.0.65534');
});
it('maps numeric pre-release to RC band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-42'), '1.50.0.10042');
});
});
describe('monotonic promotion builds within X.Y.Z', () => {
it('dev < rc.1 < rc.2 < stable', () => {
const chain = ['1.50.0-dev', '1.50.0-rc.1', '1.50.0-rc.2', '1.50.0'];
let prev = -1;
for (const v of chain) {
const build = wixMappedBuildNumber(v);
assert.ok(build > prev, `${v} build ${build} must exceed ${prev}`);
prev = build;
}
assert.equal(prev, WIX_BUILD.STABLE);
});
});
+3
View File
@@ -76,6 +76,9 @@
"windows": {
"nsis": {
"installMode": "currentUser"
},
"wix": {
"version": "1.50.0.1"
}
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ export default function MainApp() {
const handleExport = async (since: number) => {
setExportPickerOpen(false);
try {
const { exportNewAlbumsImage } = await import('@/features/album');
const { exportNewAlbumsImage } = await import('@/features/album/utils/exportNewAlbums');
const result = await exportNewAlbumsImage(since);
if (result) {
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
+1
View File
@@ -198,6 +198,7 @@ const CONTRIBUTOR_ENTRIES = [
'Per-device EQ — when System Default is selected, profiles follow the active OS default output and switch on external output changes (PR #1274)',
'Navidrome public share links — paste/search preview modal, anonymous queue playback, idle server queue-restore guard (PR #1275)',
'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)',
'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)',
],
},
{
+3 -2
View File
@@ -2,7 +2,9 @@
* Album feature — the Albums/Random/Lossless/Label browse pages + AlbumDetail
* (all lazy via deep `pages/*`, not re-exported), album cards/rows/header, the
* album track list + its mobile/header/row pieces, album detail + browse hooks,
* the album session store, per-album offline status, and album export helpers.
* the album session store, per-album offline status, and album card export.
* Easter-egg new-albums PNG export (`utils/exportNewAlbums`) stays off the barrel
* so MainApp can lazy-load it without pulling canvas/fs into the boot graph.
*
* Stays OUT (library-core / cross-cutting, consumed by this feature, not owned):
* the `lib/library/album*` browse query engine (shared with offline + other
@@ -32,7 +34,6 @@ export * from './utils/albumRecency';
export * from './utils/albumTrackListHelpers';
export * from './utils/deriveAlbumHeaderArtistRefs';
export * from './utils/exportAlbumCard';
export * from './utils/exportNewAlbums';
export { default as AlbumCard } from './components/AlbumCard';
export { default as AlbumHeader } from './components/AlbumHeader';
export { default as AlbumRow } from './components/AlbumRow';