Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Gear → **Animations** → **Default** (greeting, then motion loop). Catalog: [
<img src="docs/screenshots/AVATAR_M5_browsing_custom_environments.gif" alt="Browsing custom environments in Appearance" height="400" />
</p>

**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

Expand Down
4 changes: 2 additions & 2 deletions avatar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
225 changes: 225 additions & 0 deletions avatar/scripts/custom-envs.test.mjs
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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<typeof buildWithProbesInCustom>} */
let plainA;
/** @type {ReturnType<typeof buildWithProbesInCustom>} */
let plainB;
/** @type {ReturnType<typeof buildWithProbesInCustom>} */
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);
});
});
62 changes: 37 additions & 25 deletions avatar/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'],
},
},
},
};
});
2 changes: 1 addition & 1 deletion docs/development/project-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
Expand Down