Move brand images to proprietary/ and support multi-dir asset pipeline (#3662)

## Description:

* Move proprietary brand images (logos, favicon) from resources/images/
to proprietary/images/ to separate open-source assets from proprietary
ones
* Extend the asset pipeline (PublicAssetManifest, vite.config.ts) to
support multiple source directories (resources/ + proprietary/), so
buildAssetUrl resolves assets from either location transparently
* In dev, serve proprietary/ as a fallback middleware (registered after
Vite's publicDir handler) so resources/ takes precedence when files
exist in both. The idea is we could have placeholder assets placeholders
that can be used by forks, and only the production build uses
proprietary assets.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-16 08:42:11 -07:00
committed by GitHub
parent 31baeacdf4
commit 1ebac8e854
13 changed files with 100 additions and 62 deletions

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

+4 -6
View File
@@ -1,7 +1,5 @@
import { Howl } from "howler"; import { Howl } from "howler";
import of4 from "../../../proprietary/sounds/music/of4.mp3"; import { assetUrl } from "../../core/AssetUrls";
import openfront from "../../../proprietary/sounds/music/openfront.mp3";
import war from "../../../proprietary/sounds/music/war.mp3";
import { EventBus } from "../../core/EventBus"; import { EventBus } from "../../core/EventBus";
import { UserSettings } from "../../core/game/UserSettings"; import { UserSettings } from "../../core/game/UserSettings";
import { import {
@@ -33,19 +31,19 @@ export class SoundManager {
this.safely("initialize background music", () => { this.safely("initialize background music", () => {
this.backgroundMusic = [ this.backgroundMusic = [
new Howl({ new Howl({
src: [of4], src: [assetUrl("sounds/music/of4.mp3")],
loop: false, loop: false,
onend: this.playNext.bind(this), onend: this.playNext.bind(this),
volume: 0, volume: 0,
}), }),
new Howl({ new Howl({
src: [openfront], src: [assetUrl("sounds/music/openfront.mp3")],
loop: false, loop: false,
onend: this.playNext.bind(this), onend: this.playNext.bind(this),
volume: 0, volume: 0,
}), }),
new Howl({ new Howl({
src: [war], src: [assetUrl("sounds/music/war.mp3")],
loop: false, loop: false,
onend: this.playNext.bind(this), onend: this.playNext.bind(this),
volume: 0, volume: 0,
+44 -18
View File
@@ -233,20 +233,44 @@ export function getResourcesDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "resources"); return path.join(rootDir, "resources");
} }
export function getProprietaryDir(rootDir: string = process.cwd()): string {
return path.join(rootDir, "proprietary");
}
// Scans directories with synchronous fs.existsSync — assumes a small number of sourceDirs.
function resolveSourceDir(relativePath: string, sourceDirs: string[]): string {
for (const dir of sourceDirs) {
const candidate = path.join(dir, relativePath);
if (fs.existsSync(candidate)) {
return dir;
}
}
throw new Error(
`Asset ${relativePath} not found in any source directory: ${sourceDirs.join(", ")}`,
);
}
function resolveSourceFile(relativePath: string, sourceDirs: string[]): string {
return path.join(resolveSourceDir(relativePath, sourceDirs), relativePath);
}
export function shouldKeepRootPublicFile(relativePath: string): boolean { export function shouldKeepRootPublicFile(relativePath: string): boolean {
return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath)); return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath));
} }
export function listHashedPublicAssetPaths(resourcesDir: string): string[] { export function listHashedPublicAssetPaths(sourceDirs: string[]): string[] {
const files = new Set<string>(); const files = new Set<string>();
for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) { for (const dir of sourceDirs) {
for (const file of globSync(pattern, { if (!fs.existsSync(dir)) continue;
cwd: resourcesDir, for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) {
nodir: true, for (const file of globSync(pattern, {
dot: false, cwd: dir,
posix: true, nodir: true,
})) { dot: false,
files.add(normalizeAssetPath(file)); posix: true,
})) {
files.add(normalizeAssetPath(file));
}
} }
} }
return [...files].sort(); return [...files].sort();
@@ -264,13 +288,14 @@ export function listRootPublicFiles(resourcesDir: string): string[] {
.sort(); .sort();
} }
export function buildPublicAssetManifest(resourcesDir: string): AssetManifest { export function buildPublicAssetManifest(sourceDirs: string[]): AssetManifest {
const cached = manifestCache.get(resourcesDir); const cacheKey = sourceDirs.join("\0");
const cached = manifestCache.get(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
const hashedPublicAssetPaths = listHashedPublicAssetPaths(resourcesDir); const hashedPublicAssetPaths = listHashedPublicAssetPaths(sourceDirs);
const rawAssetPaths = hashedPublicAssetPaths.filter( const rawAssetPaths = hashedPublicAssetPaths.filter(
(relativePath) => !isDerivedPublicAsset(relativePath), (relativePath) => !isDerivedPublicAsset(relativePath),
); );
@@ -280,14 +305,14 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
const manifest: AssetManifest = {}; const manifest: AssetManifest = {};
for (const relativePath of rawAssetPaths) { for (const relativePath of rawAssetPaths) {
const absolutePath = path.join(resourcesDir, relativePath); const absolutePath = resolveSourceFile(relativePath, sourceDirs);
const hash = createContentHash(absolutePath); const hash = createContentHash(absolutePath);
manifest[relativePath] = createHashedAssetUrl(relativePath, hash); manifest[relativePath] = createHashedAssetUrl(relativePath, hash);
} }
for (const relativePath of derivedAssetPaths) { for (const relativePath of derivedAssetPaths) {
const renderedAsset = renderDerivedPublicAsset( const renderedAsset = renderDerivedPublicAsset(
resourcesDir, resolveSourceDir(relativePath, sourceDirs),
relativePath, relativePath,
manifest, manifest,
); );
@@ -301,7 +326,7 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest {
); );
} }
manifestCache.set(resourcesDir, manifest); manifestCache.set(cacheKey, manifest);
return manifest; return manifest;
} }
@@ -310,17 +335,18 @@ export function clearPublicAssetManifestCache(): void {
} }
export function createHashedPublicAssetFiles( export function createHashedPublicAssetFiles(
resourcesDir: string, sourceDirs: string[],
outDir: string, outDir: string,
assetManifest: AssetManifest, assetManifest: AssetManifest,
): void { ): void {
for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) { for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) {
const sourcePath = path.join(resourcesDir, relativePath); const sourceDir = resolveSourceDir(relativePath, sourceDirs);
const sourcePath = path.join(sourceDir, relativePath);
const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl)); const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl));
fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const renderedAsset = renderDerivedPublicAsset( const renderedAsset = renderDerivedPublicAsset(
resourcesDir, sourceDir,
relativePath, relativePath,
assetManifest, assetManifest,
); );
-11
View File
@@ -39,17 +39,6 @@ vi.mock("howler", () => {
return { Howl: MockHowl }; return { Howl: MockHowl };
}); });
// Mock music imports
vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({
default: "of4.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({
default: "openfront.mp3",
}));
vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({
default: "war.mp3",
}));
// Mock the Sounds module so tests don't depend on actual asset paths // Mock the Sounds module so tests don't depend on actual asset paths
vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => { vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => {
const actual = const actual =
+15 -15
View File
@@ -107,11 +107,11 @@ describe("PublicAssetManifest", () => {
"utf8", "utf8",
); );
const firstManifest = buildPublicAssetManifest(resourcesDir); const firstManifest = buildPublicAssetManifest([resourcesDir]);
const firstManifestHref = firstManifest["manifest.json"]; const firstManifestHref = firstManifest["manifest.json"];
const firstIconHref = firstManifest["icons/app-icon.png"]; const firstIconHref = firstManifest["icons/app-icon.png"];
createHashedPublicAssetFiles(resourcesDir, outDir, firstManifest); createHashedPublicAssetFiles([resourcesDir], outDir, firstManifest);
const firstOutput = await fs.readFile( const firstOutput = await fs.readFile(
path.join(outDir, firstManifestHref.slice(1)), path.join(outDir, firstManifestHref.slice(1)),
"utf8", "utf8",
@@ -124,7 +124,7 @@ describe("PublicAssetManifest", () => {
); );
clearPublicAssetManifestCache(); clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest(resourcesDir); const secondManifest = buildPublicAssetManifest([resourcesDir]);
const secondManifestHref = secondManifest["manifest.json"]; const secondManifestHref = secondManifest["manifest.json"];
const secondIconHref = secondManifest["icons/app-icon.png"]; const secondIconHref = secondManifest["icons/app-icon.png"];
@@ -147,8 +147,8 @@ describe("PublicAssetManifest", () => {
"utf8", "utf8",
); );
const assetManifest = buildPublicAssetManifest(resourcesDir); const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const emittedManifest = await emitHashedAsset( const emittedManifest = await emitHashedAsset(
outDir, outDir,
@@ -164,7 +164,7 @@ describe("PublicAssetManifest", () => {
await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]); await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]);
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow( expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
/manifest\.json references icons\/missing\.png/i, /manifest\.json references icons\/missing\.png/i,
); );
}); });
@@ -177,8 +177,8 @@ describe("PublicAssetManifest", () => {
{ src: "data:image/png;base64,AAA" }, { src: "data:image/png;base64,AAA" },
]); ]);
const assetManifest = buildPublicAssetManifest(resourcesDir); const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const emittedManifest = await emitHashedAsset( const emittedManifest = await emitHashedAsset(
outDir, outDir,
@@ -198,8 +198,8 @@ describe("PublicAssetManifest", () => {
"test.png", "test.png",
); );
const assetManifest = buildPublicAssetManifest(resourcesDir); const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const xmlHref = assetManifest["fonts/test.xml"]; const xmlHref = assetManifest["fonts/test.xml"];
const pngHref = assetManifest["fonts/test.png"]; const pngHref = assetManifest["fonts/test.png"];
@@ -220,12 +220,12 @@ describe("PublicAssetManifest", () => {
"test.png", "test.png",
); );
const firstManifest = buildPublicAssetManifest(resourcesDir); const firstManifest = buildPublicAssetManifest([resourcesDir]);
await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2"); await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2");
clearPublicAssetManifestCache(); clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest(resourcesDir); const secondManifest = buildPublicAssetManifest([resourcesDir]);
expect(firstManifest["fonts/test.png"]).not.toBe( expect(firstManifest["fonts/test.png"]).not.toBe(
secondManifest["fonts/test.png"], secondManifest["fonts/test.png"],
@@ -250,7 +250,7 @@ describe("PublicAssetManifest", () => {
].join("\n"), ].join("\n"),
); );
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow( expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
/missing from the asset manifest/i, /missing from the asset manifest/i,
); );
}); });
@@ -265,8 +265,8 @@ describe("PublicAssetManifest", () => {
"nested-png", "nested-png",
); );
const assetManifest = buildPublicAssetManifest(resourcesDir); const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const xmlHref = assetManifest["fonts/nested/atlas.xml"]; const xmlHref = assetManifest["fonts/nested/atlas.xml"];
const pngHref = assetManifest["fonts/nested/pages/p0.png"]; const pngHref = assetManifest["fonts/nested/pages/p0.png"];
+37 -12
View File
@@ -1,15 +1,16 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv, type Plugin } from "vite";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import { viteStaticCopy } from "vite-plugin-static-copy";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls"; import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls";
import { import {
buildPublicAssetManifest, buildPublicAssetManifest,
copyRootPublicFiles, copyRootPublicFiles,
createHashedPublicAssetFiles, createHashedPublicAssetFiles,
getProprietaryDir,
getResourcesDir, getResourcesDir,
writePublicAssetManifestModule, writePublicAssetManifestModule,
} from "./src/server/PublicAssetManifest"; } from "./src/server/PublicAssetManifest";
@@ -18,12 +19,43 @@ import {
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
function serveProprietaryDir(dir: string): Plugin {
const resolvedDir = path.resolve(dir) + path.sep;
return {
name: "serve-proprietary-dir",
configureServer(server) {
// Return a function so the middleware is registered after Vite's internal
// static-file handler (publicDir). This makes proprietary/ a fallback
// rather than taking precedence over resources/.
return () => {
server.middlewares.use((req, res, next) => {
if (!req.url) return next();
const urlPath = new URL(req.url, "http://localhost").pathname;
const filePath = path.resolve(
dir,
decodeURIComponent(urlPath).replace(/^\//, ""),
);
if (!filePath.startsWith(resolvedDir)) return next();
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.setHeader("Cache-Control", "no-cache");
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
});
};
},
};
}
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); const env = loadEnv(mode, process.cwd(), "");
const isProduction = mode === "production"; const isProduction = mode === "production";
const resourcesDir = getResourcesDir(__dirname); const resourcesDir = getResourcesDir(__dirname);
const proprietaryDir = getProprietaryDir(__dirname);
const sourceDirs = [resourcesDir, proprietaryDir];
const assetManifest: AssetManifest = isProduction const assetManifest: AssetManifest = isProduction
? buildPublicAssetManifest(resourcesDir) ? buildPublicAssetManifest(sourceDirs)
: {}; : {};
const htmlAssetData = { const htmlAssetData = {
assetManifest: JSON.stringify(assetManifest), assetManifest: JSON.stringify(assetManifest),
@@ -45,7 +77,7 @@ export default defineConfig(({ mode }) => {
closeBundle() { closeBundle() {
const outDir = path.join(__dirname, "static"); const outDir = path.join(__dirname, "static");
copyRootPublicFiles(resourcesDir, outDir); copyRootPublicFiles(resourcesDir, outDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); createHashedPublicAssetFiles(sourceDirs, outDir, assetManifest);
writePublicAssetManifestModule(outDir, assetManifest); writePublicAssetManifestModule(outDir, assetManifest);
}, },
}); });
@@ -91,6 +123,7 @@ export default defineConfig(({ mode }) => {
plugins: [ plugins: [
tsconfigPaths(), tsconfigPaths(),
...(!isProduction ? [serveProprietaryDir(proprietaryDir)] : []),
...(isProduction ...(isProduction
? [] ? []
: [ : [
@@ -106,14 +139,6 @@ export default defineConfig(({ mode }) => {
}, },
}), }),
]), ]),
viteStaticCopy({
targets: [
{
src: "proprietary/*",
dest: ".",
},
],
}),
...(isProduction ? [syncHashedPublicAssets()] : []), ...(isProduction ? [syncHashedPublicAssets()] : []),
tailwindcss(), tailwindcss(),
], ],