From cde15ad022765a6a9bafee39ab88e9efb76fa90e Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 2 Aug 2026 16:56:22 +0200 Subject: [PATCH] fix: mirror repaired icons to gamedata Repair Icons now resolves all furni asset write targets (webroot plus the live gamedata like /var/www/Gamedata) and writes downloaded or extracted icons to every missing location. Icons already present in one target are copied across instead of re-downloaded, and local .nitro bundles are searched in every target. --- src/lib/services/repair-icons.ts | 167 ++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 27 deletions(-) diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index 4b926d5c..ae55b380 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -1,4 +1,5 @@ import { existsSync, promises as fs } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; @@ -8,10 +9,61 @@ import { DEFAULT_ICON_SOURCES, listSources, } from "@/lib/services/clone-sources"; -import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { + type FurniAssetWriteTargets, + getFurniAssetWriteTargets, +} from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; import { ensureDirectories } from "./furni-import"; +function uniqueDirs(dirs: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const dir of dirs) { + const key = process.platform === "win32" ? dir.toLowerCase() : dir; + if (seen.has(key)) continue; + seen.add(key); + out.push(dir); + } + return out; +} + +async function writeIconToDirs( + icon: Buffer, + dirs: string[], + fileName: string, +): Promise { + const errors: string[] = []; + for (const dir of dirs) { + try { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, fileName), icon); + } catch (err) { + errors.push(`write icon to ${dir} failed: ${(err as Error).message}`); + } + } + return errors; +} + +async function copyIconAcrossDirs( + srcDir: string, + destDirs: string[], + fileName: string, +): Promise { + const errors: string[] = []; + const src = path.join(srcDir, fileName); + for (const dir of destDirs) { + if (dir === srcDir) continue; + try { + await fs.mkdir(dir, { recursive: true }); + await fs.copyFile(src, path.join(dir, fileName)); + } catch (err) { + errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`); + } + } + return errors; +} + export interface RepairEvent { type: "started" | "progress" | "done" | "batch_complete" | "error"; classname?: string; @@ -43,20 +95,29 @@ export async function repairMissingIcons( return { succeeded: 0, failed: 0, skipped: 0 }; } - let iconDir: string; - let nitroDir: string; + let targets: FurniAssetWriteTargets; try { - const dirs = await getFurniAssetDirs(); - iconDir = dirs.iconDir; - nitroDir = dirs.nitroDir; + targets = await getFurniAssetWriteTargets(); } catch (err) { onEvent?.({ type: "error", - message: `getFurniAssetDirs failed: ${(err as Error).message}`, + message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } + // Every location the app mirrors furni assets into — the webroot (public/) + // plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of + // them so both the CMS and the client see the icon. + const iconDirs = uniqueDirs([ + targets.iconDir, + ...targets.mirrorDirs.map((d) => d.iconDir), + ]); + const nitroDirs = uniqueDirs([ + targets.nitroDir, + ...targets.mirrorDirs.map((d) => d.nitroDir), + ]); + let items: Array<{ id: number; item_name: string }>; try { const [rows] = (await db.execute(sql` @@ -76,9 +137,16 @@ export async function repairMissingIcons( for (let i = 0; i < items.length; i++) { const { item_name: classname } = items[i]; - const iconPath = path.join(iconDir, `${classname}_icon.png`); + const fileName = `${classname}_icon.png`; - if (existsSync(iconPath)) { + // A dir may already have the icon (e.g. the full gamedata) while the + // webroot is missing it — treat the item as present when every target has it. + const presentDirs = iconDirs.filter((dir) => + existsSync(path.join(dir, fileName)), + ); + const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir)); + + if (missingDirs.length === 0) { skipped++; onEvent?.({ type: "progress", @@ -93,24 +161,55 @@ export async function repairMissingIcons( let ok = false; const warnings: string[] = []; - const nitroPath = path.join(nitroDir, `${classname}.nitro`); - if (existsSync(nitroPath)) { + // 1) Copy across mirrors — the icon already exists in one target (e.g. + // gamedata), so just fill the ones that are missing it. + if (presentDirs.length > 0) { try { - const nitroBuf = await fs.readFile(nitroPath); - const icon = extractFurniIconPng(nitroBuf); - if (icon) { - await fs.writeFile(iconPath, icon); - ok = true; - } else { - warnings.push("no icon asset in .nitro bundle"); - } + const copyErrors = await copyIconAcrossDirs( + presentDirs[0], + missingDirs, + fileName, + ); + warnings.push(...copyErrors); + ok = copyErrors.length === 0; } catch (err) { - warnings.push(`extract from .nitro failed: ${(err as Error).message}`); + warnings.push( + `copy across icon dirs failed: ${(err as Error).message}`, + ); } - } else { - warnings.push(".nitro file not found on disk"); } + // 2) Extract from a local .nitro bundle (checked across every target). + if (!ok) { + let foundNitro = false; + for (const dir of nitroDirs) { + const nitroPath = path.join(dir, `${classname}.nitro`); + if (!existsSync(nitroPath)) continue; + foundNitro = true; + try { + const nitroBuf = await fs.readFile(nitroPath); + const icon = extractFurniIconPng(nitroBuf); + if (icon) { + const writeErrors = await writeIconToDirs( + icon, + missingDirs, + fileName, + ); + warnings.push(...writeErrors); + ok = writeErrors.length === 0; + break; + } + warnings.push("no icon asset in .nitro bundle"); + } catch (err) { + warnings.push( + `extract from .nitro failed: ${(err as Error).message}`, + ); + } + } + if (!foundNitro) warnings.push(".nitro file not found on disk"); + } + + // 3) Download from configured or default public sources. if (!ok) { let sources: CloneSource[] = []; try { @@ -140,11 +239,20 @@ export async function repairMissingIcons( try { const dl = await downloadFile( `${source.iconBaseUrl}/${iconName}`, - iconPath, + path.join(targets.iconDir, fileName), { maxRetries: 1, validate: "png" }, ); if (dl.ok) { - ok = true; + const icon = await fs.readFile( + path.join(targets.iconDir, fileName), + ); + const writeErrors = await writeIconToDirs( + icon, + missingDirs, + fileName, + ); + warnings.push(...writeErrors); + ok = writeErrors.length === 0; break; } warnings.push(`${iconName} not found on ${source.name}`); @@ -159,7 +267,7 @@ export async function repairMissingIcons( // Some sources only ship icons embedded in their `.nitro` bundles — // grab the bundle and extract the catalog icon from it. if (source.nitroBaseUrl) { - const nitroTmp = path.join(nitroDir, `.repair_${classname}.nitro`); + const nitroTmp = path.join(os.tmpdir(), `.repair_${classname}.nitro`); try { const dl = await downloadFile( `${source.nitroBaseUrl}/${classname}.nitro`, @@ -169,8 +277,13 @@ export async function repairMissingIcons( if (dl.ok) { const icon = extractFurniIconPng(await fs.readFile(nitroTmp)); if (icon) { - await fs.writeFile(iconPath, icon); - ok = true; + const writeErrors = await writeIconToDirs( + icon, + missingDirs, + fileName, + ); + warnings.push(...writeErrors); + ok = writeErrors.length === 0; } else { warnings.push(`no icon asset in ${source.name} .nitro bundle`); }