1.1.1版本审核修复

[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。
跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。
[spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。
自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。
已删除后台整组补刷进程及 relatedDataPaths。
[viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。
当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。
手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。
八种语言文案及 README 已同步。
处理历史日志遗留问题
This commit is contained in:
tianmo committed 2026-09-17 13:38:22 +08:00
1 parent 473a270510
commit bb73cc9227
36 files changed
+499 -652

No files matched your search

+8 -135
View File
@@ -1,16 +1,17 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
@@ -18,10 +19,9 @@ function log(msg) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
const VERSION = 'v15';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -132,7 +132,7 @@ module.exports = async (options) => {
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
const result = await generate(options);
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
@@ -144,12 +144,10 @@ module.exports = async (options) => {
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
async function generate({ src, dest, item }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
@@ -217,7 +215,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -255,7 +252,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -263,7 +259,6 @@ async function generate({ src, dest, item, deferRefresh }) {
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
@@ -293,128 +288,6 @@ function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;