From 951173b851d7028d2f6f6e2049f2205e3bf656f8 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 17:13:52 +0300 Subject: [PATCH] Item icons from Mojang's client jar, not a CDN (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Players tab hot-linked `assets.mcasset.cloud` for every inventory slot. Three things wrong with that, and the reported symptom was the third: a third party in the middle of a private server's inventory, a grid of broken images on an air-gapped LAN, and — the one an operator actually sees — most items rendering as a three-letter text chip. That last one is not a network problem. The component asked for `item/.png` and then `block/.png` and gave up. Minecraft does not name textures that way: `grass_block` has only `_top` and `_side`, a furnace is `_front`, a log's inventory face is `_top`. Every one of those fell through to the chip whether or not the CDN answered. So the candidate list is the fix, and it is pure and tested: `textureCandidates` tries the item folder, then the block folder, then the faces, plus a short alias table for the handful whose texture shares no prefix with the id at all. Textures now come out of the client jar Mojang publishes for every version. MSMS already downloads and sha1-verifies jars from that same manifest, so this adds no new trust and no new dependency — `downloads. client` is the sibling of the `downloads.server` key `versions.ts` already reads. Measured on 1.21.4 rather than guessed: a 27 MB download, sha1 verified, of which 644 item and 1039 block textures are kept — 0.4 MB on disk. One download per Minecraft version, shared by every server on it, behind a button rather than automatic, because 27 MB is not a thing to spend on somebody's behalf. Nothing here is required for the app to work. With no assets downloaded the lookup answers null and the inventory draws the chips it drew before. Two things the smoke pins down. An id from hand-edited NBT must not become a path — `../../etc/passwd`, `a/b`, `..` and friends produce no candidates at all, which is the only thing between a crafted inventory entry and the cache directory. And the extractor's filter keeps exactly the two folders: an entity texture, an `.mcmeta`, a lang file and a nested path are all refused. This is half of #127. The other half — block map colours averaged from the same textures instead of the hand-written table — needs a PNG decoder and touches the renderer that took this session to stabilise, so it lands separately. Verified: 12/12 gates. --- src/main/core/clientAssets.ts | 233 +++++++++++++++++++++++ src/main/ipc/register.ts | 7 + src/main/smoke.ts | 95 ++++++++- src/preload/index.ts | 3 + src/renderer/src/components/ItemIcon.tsx | 36 ++-- src/renderer/src/locales/en.ts | 4 + src/renderer/src/locales/tr.ts | 4 + src/renderer/src/views/PlayersView.tsx | 61 +++++- src/shared/ipc.ts | 14 ++ src/shared/textures.ts | 117 ++++++++++++ 10 files changed, 556 insertions(+), 18 deletions(-) create mode 100644 src/main/core/clientAssets.ts create mode 100644 src/shared/textures.ts diff --git a/src/main/core/clientAssets.ts b/src/main/core/clientAssets.ts new file mode 100644 index 0000000..e513f33 --- /dev/null +++ b/src/main/core/clientAssets.ts @@ -0,0 +1,233 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import AdmZip from 'adm-zip' +import { cacheDir } from '../paths' +import { log } from '../logger' +import { httpJson, downloadFile } from './net' +import { textureCandidates, wantsTexture, textureKey, assetVersion } from '@shared/textures' +import type { AssetStatus } from '@shared/textures' + +/** + * Item and block textures, taken from Mojang's own client jar (#127). + * + * The app used to hot-link `assets.mcasset.cloud` for every item icon. That is a + * third party in the middle of a private server's inventory, it tells them which + * items an operator is looking at, and on an air-gapped LAN — a normal place to + * run this — it renders a grid of broken images. + * + * Mojang publishes the client jar for every version and MSMS already downloads + * and sha1-verifies jars from that same manifest, so this needs no new trust and + * no new dependency. One download per Minecraft version, shared by every server + * on it. + * + * Nothing here is required for the app to work: with no assets downloaded, the + * lookup answers null and the caller draws what it drew before. + */ + +const MANIFEST = 'https://launchermeta.mojang.com/mc/game/version_manifest_v2.json' + +interface Manifest { + versions: { id: string; type: string; url: string }[] +} +interface VersionDetail { + downloads?: { client?: { url: string; sha1: string; size?: number } } +} + +/** `msms-data/cache/assets//`. Beside the other per-version data. */ +function versionDir(version: string): string { + return join(cacheDir(), 'assets', version) +} + +function indexPath(version: string): string { + return join(versionDir(version), 'index.json') +} + +/** + * In-flight downloads, keyed by version. + * + * Two servers on the same version opening a profile at once must not start two + * 28 MB downloads. The promise is shared, so the second caller waits for the + * first rather than racing it into the same directory. + */ +const inFlight = new Map>() + +function readIndex(version: string): string[] { + try { + const p = indexPath(version) + if (!existsSync(p)) return [] + const j = JSON.parse(readFileSync(p, 'utf-8')) as { keys?: string[] } + return Array.isArray(j.keys) ? j.keys : [] + } catch { + return [] + } +} + +function dirSizeMB(dir: string): number { + let n = 0 + try { + for (const f of readdirSync(dir)) { + try { + n += statSync(join(dir, f)).size + } catch { + // A file that vanished between listing and stat is not worth failing a + // size report over. + } + } + } catch { + return 0 + } + return Math.round((n / 1048576) * 10) / 10 +} + +export function assetStatus(mcVersion: string): AssetStatus { + const version = assetVersion(mcVersion) + if (!version) return { version: '', ready: false, count: 0, busy: false, sizeMB: 0 } + const keys = readIndex(version) + return { + version, + ready: keys.length > 0, + count: keys.length, + busy: inFlight.has(version), + sizeMB: keys.length ? dirSizeMB(versionDir(version)) : 0 + } +} + +/** + * One texture's PNG bytes, or null. + * + * Never downloads. A lookup that could start a 28 MB fetch would make drawing an + * inventory unpredictably slow, and this is called once per slot; fetching is + * something the operator asks for, once, in the UI. + */ +export function itemTexture(mcVersion: string, id: string): Buffer | null { + const version = assetVersion(mcVersion) + if (!version) return null + const dir = versionDir(version) + for (const cand of textureCandidates(id)) { + // `textureCandidates` only ever returns `item/x` or `block/x` with `x` + // matched against [a-z0-9_], so this cannot leave the directory. + const p = join(dir, cand.replace('/', '__') + '.png') + try { + if (existsSync(p)) return readFileSync(p) + } catch { + // Unreadable file: try the next candidate rather than failing the row. + } + } + return null +} + +/** Many at once, so drawing an inventory is one call rather than forty. */ +export function itemTextures(mcVersion: string, ids: string[]): Record { + const out: Record = {} + for (const id of ids.slice(0, 256)) { + if (out[id] !== undefined) continue + const png = itemTexture(mcVersion, id) + if (png) out[id] = 'data:image/png;base64,' + png.toString('base64') + } + return out +} + +async function resolveClientJar(version: string): Promise<{ url: string; sha1: string }> { + const m = await httpJson(MANIFEST) + const entry = m.versions.find((v) => v.id === version) + if (!entry) throw new Error('unknown-version') + const detail = await httpJson(entry.url) + const dl = detail.downloads?.client + // `downloads.client`, not `.server` — the same object carries both, and the + // server jar has no assets in it at all. + if (!dl?.url || !dl.sha1) throw new Error('no-client-jar-for-version') + return { url: dl.url, sha1: dl.sha1 } +} + +/** + * Download the client jar for this version and extract the two texture folders. + * + * Idempotent: with the textures already on disk it returns immediately without + * touching the network, which is what makes it safe to call from a button an + * operator may press twice. + */ +export async function ensureClientAssets( + mcVersion: string, + onProgress?: (pct: number, note: string) => void +): Promise { + const version = assetVersion(mcVersion) + if (!version) throw new Error('unknown-version') + const have = assetStatus(version) + if (have.ready) return have + const running = inFlight.get(version) + if (running) return running + + const task = (async (): Promise => { + const dir = versionDir(version) + mkdirSync(dir, { recursive: true }) + // Staged outside the cache: a jar half-written into the assets directory + // would be indistinguishable from a finished one on the next start. + const jar = join(tmpdir(), `msms-client-${version}-${Date.now()}.jar`) + try { + onProgress?.(0, 'resolving') + const { url, sha1 } = await resolveClientJar(version) + onProgress?.(2, 'downloading') + await downloadFile(url, jar, { + sha1, + timeoutMs: 120_000, + // 0-95% is the download; extraction is the rest. A bar that sits at 100 + // while a jar is still being unpacked is a bar that looks stuck. + onProgress: (got, total) => + onProgress?.(total ? 2 + Math.round((got / total) * 93) : 50, 'downloading') + }) + onProgress?.(96, 'extracting') + const zip = new AdmZip(jar) + const keys: string[] = [] + for (const e of zip.getEntries()) { + if (e.isDirectory) continue + const name = e.entryName.replace(/\\/g, '/') + if (!wantsTexture(name)) continue + const key = textureKey(name) + if (!key) continue + // Flattened with a separator that cannot occur in a texture name, so the + // directory stays one level deep and no entry name from the archive is + // ever used as a path. + writeFileSync(join(dir, key.replace('/', '__') + '.png'), e.getData()) + keys.push(key) + } + if (!keys.length) throw new Error('no-textures-in-jar') + writeFileSync(indexPath(version), JSON.stringify({ version, keys, at: Date.now() }), 'utf-8') + log.info(`Client assets: extracted ${keys.length} textures for ${version}`) + onProgress?.(100, 'done') + return assetStatus(version) + } catch (e) { + // A failed extraction must not leave a directory that looks half-ready. + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Best effort; the missing index is what makes it not-ready anyway. + } + throw e + } finally { + try { + rmSync(jar, { force: true }) + } catch { + // A leftover temp jar is untidy, not broken. + } + inFlight.delete(version) + } + })() + + inFlight.set(version, task) + return task +} + +/** Drop one version's textures. Returns how many files went. */ +export function clearClientAssets(mcVersion: string): number { + const version = assetVersion(mcVersion) + if (!version) return 0 + const dir = versionDir(version) + const n = readIndex(version).length + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + return 0 + } + return n +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index ab09a10..ea2c416 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -21,6 +21,7 @@ import * as mods from '../core/mods' import * as bridgeInstall from '../core/bridgeInstall' import * as worldTiles from '../core/worldTiles' import * as chunkAreas from '../core/chunkAreas' +import * as clientAssets from '../core/clientAssets' import { areaChunkCount } from '@shared/chunkAreas' import { normalizeMapPage } from '@shared/mapPage' import type { AreaInput } from '@shared/chunkAreas' @@ -491,6 +492,12 @@ export function registerIpc(): void { }) return k }) + // Item textures from the client jar (#127). + H(IPC.assetsStatus, (_e, v: string) => clientAssets.assetStatus(v)) + H(IPC.assetsEnsure, (_e, v: string) => clientAssets.ensureClientAssets(v)) + H(IPC.assetsTextures, (_e, v: string, ids: string[]) => + clientAssets.itemTextures(v, Array.isArray(ids) ? ids : []) + ) // Named chunk areas (#144). The rules and the store are shared with the HTTP // routes — this is the same call the panel makes, reached a different way. H(IPC.areasList, (_e, serverId: string) => chunkAreas.listAreas(serverId)) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index de4a894..1f77218 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -128,6 +128,8 @@ import * as alertsMod from './core/alerts' import * as worldsMod from './core/worlds' import * as areasMod from '@shared/chunkAreas' import * as areasMod2 from './core/chunkAreas' +import * as tex from '@shared/textures' +import * as assetsMod from './core/clientAssets' import { isValidMcName, isValidWorldName, @@ -189,7 +191,7 @@ import { aggregateJoins, type JoinRecord } from '@shared/joins' import * as auditMod from './core/audit' import type { UptimeReport } from '@shared/uptime' import type { JavaArgsConfig, MetricSeries, ServerConfig, ServerEvent, ServerType } from '@shared/types' -import { alertsPath, uploadsDir, auditDir, dataDir } from './paths' +import { alertsPath, uploadsDir, auditDir, dataDir, cacheDir } from './paths' import { analyzeCrash } from './core/crash' import { CREATABLE_TYPES, createErrorKey } from '@shared/versions' @@ -2231,6 +2233,97 @@ export async function runWorldsSmoke(): Promise { rmSync(emptyZip, { force: true }) console.log('WORLDS-SMOKE: import refuses zip-slip and worldless archives, cleans up after itself') + // --- 12b. item textures from the client jar (#127) ---------------------- + { + // Which paths are worth trying. The old CDN only ever asked for + // `item/.png` and then `block/.png`, which is why a chest, a log + // or a grass block fell through to a three-letter text chip. + const cands = tex.textureCandidates('oak_log') + if (cands[0] !== 'item/oak_log') return fail('an item is not tried first: ' + cands[0]) + if (!cands.includes('block/oak_log')) return fail('the block folder is not tried') + if (!cands.includes('block/oak_log_top')) return fail('the top face is not tried') + // grass_block has no block/grass_block.png at all — only _top and _side. + if (!tex.textureCandidates('grass_block').includes('block/grass_block_side')) { + return fail('grass_block would still find nothing') + } + // A namespaced id is the same item. + if (tex.textureCandidates('minecraft:apple')[0] !== 'item/apple') return fail('the namespace was not stripped') + // An id from hand-edited NBT must not become a path. This is the only + // thing between a crafted inventory entry and the cache directory. + for (const bad of ['../../etc/passwd', 'a/b', '..', '', 'x'.repeat(80), 'Apple!', 'a b']) { + const c = tex.textureCandidates(bad) + if (c.length) return fail('an unsafe id produced candidates: ' + JSON.stringify(bad)) + } + + // What the extractor keeps. The client jar holds thousands of files and + // only two folders are ever looked up. + if (!tex.wantsTexture('assets/minecraft/textures/item/apple.png')) return fail('an item texture was skipped') + if (!tex.wantsTexture('assets/minecraft/textures/block/stone.png')) return fail('a block texture was skipped') + for (const skip of [ + 'assets/minecraft/textures/entity/creeper/creeper.png', + 'assets/minecraft/textures/item/apple.png.mcmeta', + 'assets/minecraft/lang/en_us.json', + '../../../evil.png', + 'assets/minecraft/textures/item/sub/dir.png' + ]) { + if (tex.wantsTexture(skip)) return fail('the extractor would keep ' + skip) + } + if (tex.textureKey('assets/minecraft/textures/block/stone.png') !== 'block/stone') { + return fail('the texture key is wrong') + } + + // Which version's jar. Snapshots and modded strings reduce to the release + // they are built on; there is no point downloading twenty snapshots to + // get the same apple. + for (const [given, want] of [ + ['1.21.4', '1.21.4'], + ['1.21', '1.21'], + ['1.21.4-pre2', '1.21.4'], + ['1.20.1-forge-47.2.0', '1.20.1'], + ['nonsense', ''], + ['', ''] + ] as const) { + if (tex.assetVersion(given) !== want) { + return fail('assetVersion(' + JSON.stringify(given) + ') = ' + tex.assetVersion(given)) + } + } + + // ...and the round trip, without the network: write a texture where the + // extractor would have put it and check the lookup finds it through the + // candidate list rather than only by an exact name. + const av = tex.assetVersion('1.21.4') + const adir = join(cacheDir(), 'assets', av) + const hadIndex = existsSync(join(adir, 'index.json')) + if (!hadIndex) { + mkdirSync(adir, { recursive: true }) + // A one-pixel PNG. Only the bytes coming back matter here. + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ) + writeFileSync(join(adir, 'block__grass_block_side.png'), png) + writeFileSync(join(adir, 'item__apple.png'), png) + writeFileSync(join(adir, 'index.json'), JSON.stringify({ version: av, keys: ['item/apple'] })) + try { + if (!assetsMod.itemTexture('1.21.4', 'apple')) return fail('a written item texture was not found') + // The case the CDN got wrong: found by suffix, not by exact name. + if (!assetsMod.itemTexture('1.21.4', 'grass_block')) return fail('grass_block was not found by alias') + if (assetsMod.itemTexture('1.21.4', 'nonexistent_thing')) return fail('a missing texture returned bytes') + if (assetsMod.itemTexture('1.21.4', '../../evil')) return fail('an unsafe id reached the disk') + const many = assetsMod.itemTextures('1.21.4', ['apple', 'apple', 'nonexistent_thing']) + if (!many['apple']?.startsWith('data:image/png;base64,')) return fail('the batch lookup returned no data url') + if (many['nonexistent_thing']) return fail('the batch lookup invented a texture') + if (!assetsMod.assetStatus('1.21.4').ready) return fail('the status does not see the index') + // An unknown version is not ready and does not throw — an operator on + // a modded string must still get an app, not an exception. + if (assetsMod.assetStatus('nonsense').ready) return fail('an unknown version claimed to be ready') + } finally { + rmSync(adir, { recursive: true, force: true }) + } + } + console.log('WORLDS-SMOKE: client-jar textures OK (candidates, extractor filter, safe ids, local lookup)') + } + // --- 12. chunk areas: the rules four map surfaces have to share (#144) --- { const at = (rs: number[][]): { x1: number; z1: number; x2: number; z2: number }[] => diff --git a/src/preload/index.ts b/src/preload/index.ts index 15e6d3c..79f5ff2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -83,6 +83,9 @@ const api: MsmsApi = { mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks), clearMapCache: () => ipcRenderer.invoke(IPC.mapCacheClear), setApiKeyDisabled: (id, disabled) => ipcRenderer.invoke(IPC.apiKeyDisabled, id, disabled), + assetStatus: (v) => ipcRenderer.invoke(IPC.assetsStatus, v), + ensureAssets: (v) => ipcRenderer.invoke(IPC.assetsEnsure, v), + itemTextures: (v, ids) => ipcRenderer.invoke(IPC.assetsTextures, v, ids), listChunkAreas: (serverId) => ipcRenderer.invoke(IPC.areasList, serverId), saveChunkArea: (serverId, input) => ipcRenderer.invoke(IPC.areasSave, serverId, input), deleteChunkArea: (serverId, areaId) => ipcRenderer.invoke(IPC.areasDelete, serverId, areaId), diff --git a/src/renderer/src/components/ItemIcon.tsx b/src/renderer/src/components/ItemIcon.tsx index e1c2704..1fe38fd 100644 --- a/src/renderer/src/components/ItemIcon.tsx +++ b/src/renderer/src/components/ItemIcon.tsx @@ -1,43 +1,53 @@ import { useState, useEffect } from 'react' /** - * Minecraft item/block icon from an online, always-updatable source - * (assets.mcasset.cloud, which mirrors Mojang textures by version). - * Tries item/ then block/, then falls back to a text chip. + * A Minecraft item or block icon. + * + * Drawn from the textures MSMS extracted out of Mojang's own client jar (#127), + * which is why this takes a `src` rather than building a URL: the picture is a + * `data:` URI the main process produced from a local file, so there is no third + * party in the middle of a private server's inventory and it works with no + * internet at all. + * + * It used to hot-link `assets.mcasset.cloud`. That host does not have a texture + * at `item/.png` for most blocks, so a chest, a log or a grass block fell + * through to the three-letter chip — the "icons aren't loading" this replaces. + * + * No `src` means the operator has not downloaded the assets for this version, or + * the id has no texture at all. The chip is the fallback, and it is not + * politeness: it is what keeps an inventory readable before anything has been + * downloaded. */ export function ItemIcon({ id, - version, + src, size = 32 }: { id: string - version?: string + src?: string size?: number }): JSX.Element { - const ver = version && /^1\.\d+(\.\d+)?$/.test(version) ? version : '1.21.4' - const [stage, setStage] = useState(0) // 0=item, 1=block, 2=text - useEffect(() => setStage(0), [id, ver]) + const [broken, setBroken] = useState(false) + useEffect(() => setBroken(false), [src, id]) - if (stage >= 2) { + if (!src || broken) { return (
{id.slice(0, 3)}
) } - const folder = stage === 0 ? 'item' : 'block' - const url = `https://assets.mcasset.cloud/${ver}/assets/minecraft/textures/${folder}/${id}.png` return ( {id} setStage((s) => s + 1)} + onError={() => setBroken(true)} /> ) } diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index bce7969..8e1d2ab 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -462,6 +462,10 @@ export default { inventory: 'Inventory', enderChest: 'Ender chest', inventorySoon: 'Live inventory viewer is coming in a later update.', + assetsMissing: 'Item pictures for {{version}} are not downloaded yet.', + assetsGet: 'Download textures', + assetsFetching: 'Downloading…', + assetsReady: 'Item textures ready.', noInventory: 'No saved inventory (the player must have logged in and the world saved). Icons load from an online source.', clickHint: 'Click a player card for details and actions.', offline: 'Offline' diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index f2b47d3..bb145c5 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -465,6 +465,10 @@ const tr: typeof en = { inventory: 'Envanter', enderChest: 'Ender sandığı', inventorySoon: 'Canlı envanter görüntüleyici sonraki güncellemede gelecek.', + assetsMissing: '{{version}} için item görselleri henüz indirilmedi.', + assetsGet: 'Görselleri indir', + assetsFetching: 'İndiriliyor…', + assetsReady: 'Item görselleri hazır.', noInventory: 'Kayıtlı envanter yok (oyuncu giriş yapmış ve dünya kaydedilmiş olmalı). Simgeler çevrimiçi kaynaktan yüklenir.', clickHint: 'Ayrıntılar ve işlemler için bir oyuncu kartına tıklayın.', offline: 'Çevrimdışı' diff --git a/src/renderer/src/views/PlayersView.tsx b/src/renderer/src/views/PlayersView.tsx index be1a8c5..0a701dc 100644 --- a/src/renderer/src/views/PlayersView.tsx +++ b/src/renderer/src/views/PlayersView.tsx @@ -20,13 +20,13 @@ import { Drumstick, Sparkles, Clock, - Map as MapIcon -} from 'lucide-react' + Map as MapIcon, Download} from 'lucide-react' import { useStore } from '../store' import { PlayerAvatar } from '../components/PlayerAvatar' import { LiveMap } from '../components/LiveMap' import { ItemIcon } from '../components/ItemIcon' import type { PlayerInfo } from '@shared/types' +import type { AssetStatus } from '@shared/textures' const GAMEMODES = ['survival', 'creative', 'adventure', 'spectator'] const DIFFICULTIES = ['peaceful', 'easy', 'normal', 'hard'] @@ -98,6 +98,46 @@ export function PlayersView(): JSX.Element { void load() }, [load]) + // ---- item textures from the client jar (#127) ---- + const [textures, setTextures] = useState>({}) + const [assets, setAssets] = useState(null) + const [fetching, setFetching] = useState(false) + + useEffect(() => { + if (!mcVersion) return + window.msms.assetStatus(mcVersion).then(setAssets).catch(() => setAssets(null)) + }, [mcVersion]) + + // One call for the whole inventory rather than one per slot, and only for the + // ids actually on screen. + useEffect(() => { + const ids = [ + ...(selected?.inventory ?? []).map((i) => i.id), + ...(selected?.enderChest ?? []).map((i) => i.id) + ] + if (!mcVersion || !ids.length || !assets?.ready) { + setTextures({}) + return + } + window.msms + .itemTextures(mcVersion, [...new Set(ids)]) + .then(setTextures) + .catch(() => setTextures({})) + }, [mcVersion, selected, assets?.ready]) + + const downloadAssets = async (): Promise => { + if (!mcVersion) return + setFetching(true) + try { + setAssets(await window.msms.ensureAssets(mcVersion)) + toast('success', 'players.assetsReady') + } catch (e) { + toast('error', String(e)) + } finally { + setFetching(false) + } + } + useEffect(() => { if (!running) return const iv = setInterval(load, 5000) @@ -250,11 +290,24 @@ export function PlayersView(): JSX.Element { {t('players.inventory')} + {/* The offer, where the missing pictures are. Measured on 1.21.4: a + 27 MB jar from Mojang, of which 1683 textures and 0.4 MB are + kept — once per Minecraft version, shared by every server on it. + An explicit action rather than something that happens on open, + because 27 MB is not a thing to spend on somebody's behalf. */} + {mcVersion && !assets?.ready && (selected.inventory?.length ?? 0) > 0 && ( +
+ {t('players.assetsMissing', { version: assets?.version || mcVersion })} + +
+ )} {selected.inventory && selected.inventory.length > 0 ? (
{selected.inventory.map((it, i) => (
- + {it.count > 1 && {it.count}}
))} @@ -275,7 +328,7 @@ export function PlayersView(): JSX.Element {
{selected.enderChest.map((it, i) => (
- + {it.count > 1 && {it.count}}
))} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 11d723d..2bc9885 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -44,6 +44,7 @@ import type { ModEntry, ModrinthDetail, ModrinthHit, ModUpdateReport } from './m import type { BridgeInstallResult, BridgeStatus } from './bridgeRelease' import type { StructureMark } from './regionFormat' import type { AreaInput, ChunkArea } from './chunkAreas' +import type { AssetStatus } from './textures' import type { WebStatus, WebUserView, @@ -138,6 +139,9 @@ export const IPC = { mapTiles: 'map:tiles', mapCacheClear: 'map:cache-clear', apiKeyDisabled: 'apikey:disabled', + assetsStatus: 'assets:status', + assetsEnsure: 'assets:ensure', + assetsTextures: 'assets:textures', areasList: 'areas:list', areasSave: 'areas:save', areasDelete: 'areas:delete', @@ -363,6 +367,16 @@ export interface MsmsApi { /** Drop every cached region. Returns how many files went. */ clearMapCache(): Promise + /** + * Item textures from Mojang's client jar (#127), so an inventory draws with + * no third party in the middle and no internet at all. + */ + assetStatus(mcVersion: string): Promise + /** Download and extract them. One jar per Minecraft version. */ + ensureAssets(mcVersion: string): Promise + /** `id -> data:image/png;base64,...` for the ids that have a texture on disk. */ + itemTextures(mcVersion: string, ids: string[]): Promise> + /** * Named chunk areas (#144). The operator's own list — hidden areas included, * which is the difference between this and what the public site is served. diff --git a/src/shared/textures.ts b/src/shared/textures.ts new file mode 100644 index 0000000..f4dfe6f --- /dev/null +++ b/src/shared/textures.ts @@ -0,0 +1,117 @@ +/** + * Where a Minecraft item's picture lives inside the client jar (#127). + * + * Pure, because the answer is a list of guesses and guesses are exactly the kind + * of thing that should be testable without a 28 MB download. The extractor uses + * this to decide what to keep, and the lookup uses it to decide what to try. + */ + +/** What the app knows about one version's extracted textures. */ +export interface AssetStatus { + version: string + ready: boolean + /** How many textures are on disk. */ + count: number + /** True while a download for this version is in flight. */ + busy: boolean + sizeMB: number +} + +/** The two texture folders that matter. Order is the lookup order. */ +export const TEXTURE_ROOTS = ['item', 'block'] as const + +/** + * Suffixes tried for a BLOCK whose id names no texture of its own. + * + * Most blocks have `block/.png`, but the ones a player is most likely to be + * holding do not: `grass_block` is `grass_block_top` and `_side`, a door is + * `_top` and `_bottom`, a furnace is `_front`. Guessing the front-facing or top + * texture is what makes a chest look like a chest instead of a text chip. + * + * `_top` before `_front` before `_side`: for the blocks where several exist, the + * top is the face the inventory icon is drawn from. + */ +const BLOCK_SUFFIXES = ['_top', '_front', '_side', '_0', '_still', '_stage0'] + +/** + * A few ids whose texture shares no prefix with them at all, so no suffix rule + * can find it. Deliberately short: this is a list of exceptions, and every entry + * is a thing an operator will actually see in an inventory. + */ +const ALIASES: Record = { + // Items whose texture is named for the thing rather than the item. + wheat_seeds: 'item/wheat_seeds', + redstone: 'item/redstone', + // Blocks placed from an item with a different texture name. + cobweb: 'block/cobweb', + grass_block: 'block/grass_block_side', + dirt_path: 'block/dirt_path_top', + farmland: 'block/farmland', + water_bucket: 'item/water_bucket', + lava_bucket: 'item/lava_bucket', + // The three that are drawn from an entity texture and have no block/item png. + // Named so the lookup fails fast rather than trying eight paths. + chest: 'block/oak_planks', + trapped_chest: 'block/oak_planks', + ender_chest: 'block/obsidian' +} + +/** + * Every path worth trying for one id, most likely first. + * + * `id` is expected to be already namespace-stripped and validated — the caller + * is `itemIconId`, which refuses anything that is not a plain `[a-z0-9_]` id. + * An empty or unsafe id yields no candidates rather than a path to try, so a + * hand-edited NBT entry cannot reach into the cache directory. + */ +export function textureCandidates(id: string): string[] { + const clean = String(id || '').replace(/^minecraft:/, '').trim().toLowerCase() + if (!/^[a-z0-9_]{1,64}$/.test(clean)) return [] + const out: string[] = [] + const push = (p: string): void => { + if (!out.includes(p)) out.push(p) + } + const alias = ALIASES[clean] + if (alias) push(alias) + push('item/' + clean) + push('block/' + clean) + for (const s of BLOCK_SUFFIXES) push('block/' + clean + s) + return out +} + +/** + * Is this a texture the extractor should keep? + * + * The client jar holds thousands of files; only two folders are ever looked up, + * and keeping the rest would turn a cache into a copy of the jar. Animated + * textures ship a `.mcmeta` beside the png — the png itself is a vertical strip + * of frames, which draws as a squashed column, so those are skipped rather than + * shown wrong. + */ +export function wantsTexture(entryPath: string): boolean { + const m = /^assets\/minecraft\/textures\/(item|block)\/([a-z0-9_]+)\.png$/.exec( + entryPath.replace(/\\/g, '/') + ) + return !!m +} + +/** `assets/minecraft/textures/item/apple.png` -> `item/apple`. */ +export function textureKey(entryPath: string): string { + const m = /^assets\/minecraft\/textures\/(item|block)\/([a-z0-9_]+)\.png$/.exec( + entryPath.replace(/\\/g, '/') + ) + return m ? m[1] + '/' + m[2] : '' +} + +/** + * The Minecraft version whose assets should be used for a server. + * + * Snapshots, release candidates and modded version strings all reduce to the + * release they are built on, because Mojang publishes a client jar per version + * id and there is no point downloading twenty snapshots of one release to get + * the same apple. Anything unrecognisable yields '' and the caller falls back. + */ +export function assetVersion(mcVersion: string): string { + const m = /^(1\.\d{1,2}(?:\.\d{1,2})?)/.exec(String(mcVersion || '').trim()) + return m ? m[1] : '' +}