diff --git a/CHANGELOG.md b/CHANGELOG.md index dd235ea..f05c525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- Local `custom/` environment media is dropped from **every** production build, not just `dist:win` — `npm run build` and `npm run desktop` no longer hash contributor trial GIFs into `dist/`. Only the dev server reads the folder; `AVATAR_INCLUDE_CUSTOM=1` opts a production build back in. Replaces the `AVATAR_SHIP=1` flag, which is removed. A reformat or rename of the glob now fails the build instead of silently bundling the folder, and `npm test` covers the embargo (`scripts/custom-envs.test.mjs`) — a clean checkout has an empty `custom/`, so a green build proves nothing on its own. (#13) + ## [0.6.0] — 2026-08-07 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29089ca..b599095 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,7 @@ Docs-only PRs still need a clear description; changelog entry optional unless th - If a feature is **Electron-only**, say so in UI copy and docs; do not silently no-op in a confusing way when possible. - If you fix something in the renderer, smoke-test **`dev:desktop`** for overlay/audio when the area touches those systems. -- Shipping builds use `AVATAR_SHIP=1` on `dist:win` so local `custom/` environment GIFs are **not** packaged — do not “fix” shipping by committing trial media into `custom/`. +- Every production build (`build`, `desktop`, `dist:win`) drops local `custom/` environment media from the bundle; only the dev server keeps it. Use `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` if you deliberately want trial media in a production build — and do not “fix” shipping by committing trial media into `custom/`. ### Settings and persistence diff --git a/README.md b/README.md index 242976a..31462f0 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Gear → **Animations** → **Default** (greeting, then motion loop). Catalog: [ Browsing custom environments in Appearance

-**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default (local only; not shipped in the installer). +**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default — picked up by the dev server only, and left out of every production build. ## Documentation diff --git a/avatar/package.json b/avatar/package.json index d07582b..e26b0c4 100644 --- a/avatar/package.json +++ b/avatar/package.json @@ -15,8 +15,8 @@ "desktop": "npm run build && cross-env NODE_ENV=production electron .", "build": "vite build", "icons": "node scripts/make-icons.mjs", - "test": "node --test electron/*.test.cjs", - "dist:win": "npm run icons && cross-env AVATAR_SHIP=1 vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis", + "test": "node --test electron/*.test.cjs scripts/*.test.mjs", + "dist:win": "npm run icons && vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis", "lint": "eslint . --max-warnings=0", "preview": "vite preview" }, diff --git a/avatar/scripts/custom-envs.test.mjs b/avatar/scripts/custom-envs.test.mjs new file mode 100644 index 0000000..536b818 --- /dev/null +++ b/avatar/scripts/custom-envs.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { after, before, describe } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import viteConfig from '../vite.config.js'; + +// A clean checkout has an empty custom/, so a build alone can never show that +// the embargo still works — it would pass just as green with the plugin +// deleted. These tests run the real plugin over the real environments.js +// instead, which is what actually decides whether trial media gets bundled. + +const here = path.dirname(fileURLToPath(import.meta.url)); +const environmentsId = path.join(here, '..', 'src', 'config', 'environments.js'); +const environmentsSource = fs.readFileSync(environmentsId, 'utf8'); + +/** Rollup hands `transform` a plugin context; only `error` matters here. */ +const rollupContext = { + error(message) { + throw new Error(typeof message === 'string' ? message : message.message); + }, +}; + +/** @param {{ command: string, mode?: string }} env */ +function transformEnvironments(env, code = environmentsSource) { + const { plugins } = viteConfig({ mode: 'production', ...env }); + const plugin = plugins + .flat(Infinity) + .find((entry) => entry && entry.name === 'avatar-strip-custom-envs'); + assert.ok(plugin, 'vite.config.js no longer registers avatar-strip-custom-envs'); + return plugin.transform.call(rollupContext, code, environmentsId); +} + +function withoutOptIn(context) { + const previous = process.env.AVATAR_INCLUDE_CUSTOM; + delete process.env.AVATAR_INCLUDE_CUSTOM; + context.after(() => { + if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM; + else process.env.AVATAR_INCLUDE_CUSTOM = previous; + }); +} + +test('a production build strips the custom/ glob', (context) => { + withoutOptIn(context); + + const result = transformEnvironments({ command: 'build' }); + + assert.ok(result, 'expected the plugin to rewrite environments.js'); + assert.match(result.code, /const customModules = \{\};/); + assert.doesNotMatch(result.code, /import\.meta\.glob/); +}); + +test('the dev server keeps the custom/ glob', (context) => { + withoutOptIn(context); + + assert.equal(transformEnvironments({ command: 'serve' }), null); +}); + +test('AVATAR_INCLUDE_CUSTOM=1 opts a build back in', (context) => { + withoutOptIn(context); + process.env.AVATAR_INCLUDE_CUSTOM = '1'; + + assert.equal(transformEnvironments({ command: 'build' }), null); +}); + +test('a build fails rather than silently bundling custom/ media', (context) => { + withoutOptIn(context); + const renamed = environmentsSource.replace( + 'const customModules = import.meta.glob(', + 'const customEnvModules = import.meta.glob(', + ); + assert.notEqual(renamed, environmentsSource, 'fixture no longer matches environments.js'); + + assert.throws(() => transformEnvironments({ command: 'build' }, renamed), { + message: /custom\/ media would be bundled/, + }); +}); + +test('other modules are left alone', (context) => { + withoutOptIn(context); + + const other = path.join(here, '..', 'src', 'config', 'userSettings.js'); + const { plugins } = viteConfig({ command: 'build', mode: 'production' }); + const plugin = plugins + .flat(Infinity) + .find((entry) => entry && entry.name === 'avatar-strip-custom-envs'); + + assert.equal(plugin.transform.call(rollupContext, environmentsSource, other), null); +}); + +// The tests above assert the mechanism — that one glob in environments.js gets +// rewritten. What we actually promise is stronger and only a real build can +// show it: what sits in custom/ makes no difference to the bundle at all. +// Three builds, shared by the assertions below, are enough to pin that down. + +const projectRoot = path.join(here, '..'); +const viteBin = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js'); +const customDir = path.join(projectRoot, 'src', 'assets', 'environments', 'custom'); + +/** Deliberately different in count, extension and size from `probeSetB`. */ +const probeSetA = [ + ['a1.gif', 64 * 1024], + ['a2.png', 512 * 1024], +]; +const probeSetB = [ + ['b1.gif', 1024], + ['b2.jpg', 3 * 1024 * 1024], + ['b3.jpeg', 128 * 1024], +]; + +/** Every emitted file as `relative/path md5`, sorted — not just asset names. */ +function manifestOf(dir) { + /** @type {string[]} */ + const entries = []; + const walk = (current) => { + for (const name of fs.readdirSync(current)) { + const full = path.join(current, name); + if (fs.statSync(full).isDirectory()) walk(full); + else { + const hash = createHash('md5').update(fs.readFileSync(full)).digest('hex'); + entries.push(`${path.relative(dir, full).replace(/\\/g, '/')} ${hash}`); + } + } + }; + walk(dir); + return entries.sort(); +} + +/** + * Plants a named set of files in custom/ (leaving any local trial media + * alone), builds, removes them again, and returns the manifest. The stem + * depends only on `label`, so two builds of the same set differ by nothing but + * their env — otherwise a comparison between them would also be reading + * unrelated filename noise. + * @param {string} label + * @param {[string, number][]} probeSet + * @param {Record} env + */ +function buildWithProbesInCustom(label, probeSet, env) { + const stem = `probe-${process.pid}-${label}`; + const planted = probeSet.map(([suffix, size]) => { + const file = path.join(customDir, `${stem}-${suffix}`); + fs.writeFileSync(file, Buffer.alloc(size)); + return file; + }); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'avatar-custom-envs-')); + + try { + execFileSync(process.execPath, [viteBin, 'build', '--outDir', outDir, '--emptyOutDir'], { + cwd: projectRoot, + env: { ...process.env, ...env }, + stdio: 'pipe', + }); + } catch (cause) { + // stdio: 'pipe' keeps the build quiet when it works; when it does not, the + // reason has to reach the CI log or this failure is undiagnosable. + throw new Error(`vite build failed for probe set ${label}:\n${cause.stderr}`, { cause }); + } finally { + for (const file of planted) fs.rmSync(file, { force: true }); + } + + return { stem, outDir, manifest: manifestOf(outDir) }; +} + +describe('a real build', { timeout: 600_000 }, () => { + /** @type {ReturnType} */ + let plainA; + /** @type {ReturnType} */ + let plainB; + /** @type {ReturnType} */ + let optedInA; + + before(() => { + const previous = process.env.AVATAR_INCLUDE_CUSTOM; + delete process.env.AVATAR_INCLUDE_CUSTOM; + try { + plainA = buildWithProbesInCustom('a', probeSetA, {}); + plainB = buildWithProbesInCustom('b', probeSetB, {}); + optedInA = buildWithProbesInCustom('a', probeSetA, { AVATAR_INCLUDE_CUSTOM: '1' }); + } finally { + if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM; + else process.env.AVATAR_INCLUDE_CUSTOM = previous; + } + }); + + after(() => { + for (const built of [plainA, plainB, optedInA]) { + if (built) fs.rmSync(built.outDir, { force: true, recursive: true }); + } + }); + + test('leaves custom/ media out of the bundle', () => { + assert.deepEqual( + plainA.manifest.filter((entry) => entry.includes(plainA.stem)), + [], + ); + }); + + test('emits that same media when opted in', () => { + // Without this control the test above passes just as green when the probes + // are never planted or the assertion looks in the wrong place. + assert.equal( + optedInA.manifest.filter((entry) => entry.includes(optedInA.stem)).length, + probeSetA.length, + ); + }); + + test('is byte-identical whatever custom/ holds', () => { + // Stronger than "our probes are absent": two different populations of + // custom/ must produce the same files with the same contents, so nothing + // in there can influence chunking or content hashes either. + assert.deepEqual(plainA.manifest, plainB.manifest); + }); + + test('and that comparison would notice if it were not', () => { + // Same probe set, same filenames, same manifest function — the only thing + // that differs is the opt-in, so a difference here can only mean custom/ + // reached the bundle. Proves the comparison above can actually see one. + assert.notDeepEqual(optedInA.manifest, plainA.manifest); + }); +}); diff --git a/avatar/vite.config.js b/avatar/vite.config.js index b5c62fd..b568260 100644 --- a/avatar/vite.config.js +++ b/avatar/vite.config.js @@ -2,41 +2,53 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; /** - * Production / installer builds must not bundle local custom/ trial GIFs. - * Dev keeps the eager glob so dropping files into custom/ still works. + * Local `custom/` trial media is contributor-only and must never reach a + * production bundle — `build`, `desktop` and `dist:win` all drop it. The dev + * server keeps the eager glob so dropping files into custom/ still works; set + * AVATAR_INCLUDE_CUSTOM=1 to keep them in a build on purpose. + * @param {boolean} includeCustom */ -function stripCustomEnvsForShip() { - const ship = process.env.AVATAR_SHIP === '1'; +function stripCustomEnvs(includeCustom) { return { name: 'avatar-strip-custom-envs', transform(code, id) { - if (!ship) return null; + if (includeCustom) return null; const norm = id.replace(/\\/g, '/'); if (!norm.endsWith('/config/environments.js')) return null; if (!code.includes('import.meta.glob')) return null; - return { - code: code.replace( - /const customModules = import\.meta\.glob\([\s\S]*?\);/, - 'const customModules = {};', - ), - map: null, - }; + const stripped = code.replace( + /const customModules = import\.meta\.glob\([\s\S]*?\);/, + 'const customModules = {};', + ); + // A silent no-op here would quietly ship every file in custom/, so a + // rename or reformat of the glob must break the build, not the embargo. + if (stripped === code) { + this.error( + 'avatar-strip-custom-envs: the customModules glob in config/environments.js no longer ' + + 'matches this plugin, so custom/ media would be bundled. Update the pattern.', + ); + } + return { code: stripped, map: null }; }, }; } -export default defineConfig({ - base: './', - assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'], - plugins: [react(), stripCustomEnvsForShip()], - server: { - watch: { - // `npm run thumbs` writes generated portraits into this directory while - // the dev server is running. Watching it means each write invalidates the - // module graph, reloads the generator, and starts the whole run again — - // an endless loop that also interrupts in-flight VRM loads. These are - // build assets; they never need hot reload. - ignored: ['**/src/assets/avatars/thumbs/**'], +export default defineConfig(({ command }) => { + // `serve` is the dev server; anything that emits a bundle drops custom/. + const includeCustom = command === 'serve' || process.env.AVATAR_INCLUDE_CUSTOM === '1'; + return { + base: './', + assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'], + plugins: [react(), stripCustomEnvs(includeCustom)], + server: { + watch: { + // `npm run thumbs` writes generated portraits into this directory while + // the dev server is running. Watching it means each write invalidates the + // module graph, reloads the generator, and starts the whole run again — + // an endless loop that also interrupts in-flight VRM loads. These are + // build assets; they never need hot reload. + ignored: ['**/src/assets/avatars/thumbs/**'], + }, }, - }, + }; }); diff --git a/docs/development/project-layout.md b/docs/development/project-layout.md index 5cb3bdc..aba9fa2 100644 --- a/docs/development/project-layout.md +++ b/docs/development/project-layout.md @@ -43,7 +43,7 @@ End users personalize via **Settings → Directories** and **VRoid Hub** (see [U | `npm run dist:win` | Contributors | Build Windows NSIS installer (local `desktop-setup/` output) | | `npm run build` | CI / web | Production Vite bundle | | `npm run lint` | Contributors / CI | ESLint (`src/`, `electron/**/*.cjs`, scripts; `--max-warnings=0`) | -| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) | +| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) and build-tooling tests (`scripts/*.test.mjs`, which run three real `vite build`s, so the suite takes ~15s) | | `npm run thumbs` | Contributors | Re-render committed avatar portraits into `src/assets/avatars/thumbs/` | ### Continuous integration diff --git a/docs/environments.md b/docs/environments.md index 3ec5f1b..b181ee4 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -66,12 +66,14 @@ Built-ins are **never** replaced by a custom env directory (unlike avatars). The `custom/` folder is for **local trial media** when Directories is still Default. Files inside it are **not** committed to git (the empty folder is kept via `.gitkeep`). -**Shipped Windows builds** do not pack local Custom media. Prefer Settings → Directories for installer users. +**Production builds** (`npm run build`, `npm run desktop`, `npm run dist:win`) do not pack local Custom media — only the dev server reads this folder. Prefer Settings → Directories for installer users. 1. Drop media into `avatar/src/assets/environments/custom/` (`.gif`, `.png`, `.jpg`, `.jpeg`). -2. Restart Vite / Electron so Vite’s glob picks them up. +2. Restart Vite (`npm run dev` / `npm run dev:desktop`) so Vite’s glob picks them up. 3. Gear → Appearance → Environments → open **Custom**. +To check trial media against a production bundle, opt in explicitly — `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` (works in PowerShell, `cmd` and bash). + While Custom is open, built-in thumbs (Stars / Code / Bloom / None) hide so the library has room; close Custom to show them again. Scroll the Custom grid; Color fade stays visible underneath.