diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dccd0c4..2e248e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 54c5d111..f56e9bac 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -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. diff --git a/package.json b/package.json index 713f5cc1..2e145dea 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/sync-tauri-version-from-package.js b/scripts/sync-tauri-version-from-package.js index 9c4c201f..bcd66978 100644 --- a/scripts/sync-tauri-version-from-package.js +++ b/scripts/sync-tauri-version-from-package.js @@ -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', +}); diff --git a/scripts/sync-version-pipeline.test.mjs b/scripts/sync-version-pipeline.test.mjs new file mode 100644 index 00000000..a6c9db76 --- /dev/null +++ b/scripts/sync-version-pipeline.test.mjs @@ -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'); + }); +}); diff --git a/scripts/sync-wix-bundle-version.mjs b/scripts/sync-wix-bundle-version.mjs new file mode 100644 index 00000000..3d579f11 --- /dev/null +++ b/scripts/sync-wix-bundle-version.mjs @@ -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`); diff --git a/scripts/wix-bundle-version.mjs b/scripts/wix-bundle-version.mjs new file mode 100644 index 00000000..5f751030 --- /dev/null +++ b/scripts/wix-bundle-version.mjs @@ -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; +} diff --git a/scripts/wix-bundle-version.test.mjs b/scripts/wix-bundle-version.test.mjs new file mode 100644 index 00000000..22af4012 --- /dev/null +++ b/scripts/wix-bundle-version.test.mjs @@ -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); + }); +}); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b9427135..01eb14a8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -76,6 +76,9 @@ "windows": { "nsis": { "installMode": "currentUser" + }, + "wix": { + "version": "1.50.0.1" } } } diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 2e1e3624..5971af87 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -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)` : ''; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index c42bd0ca..c6b99d74 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/features/album/index.ts b/src/features/album/index.ts index 6d09c447..82c66119 100644 --- a/src/features/album/index.ts +++ b/src/features/album/index.ts @@ -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';