diff --git a/README.md b/README.md index f45f363..bc8b10e 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,11 @@ SpineViewer/ - 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局 - 零时长动画按单帧静态姿势预览 - 自动适配并居中 - - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 -4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 - - 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。 - - JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,不进入全库资源扫描或补刷队列,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。 - - 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。 - - 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。 - - Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。 + - 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认 +4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中的手动设置都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。自动流程只写入 Eagle 为当前素材提供的缩略图目标,不会改写同组其他素材的自定义缩略图。 + - JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,也不进入资源扫描,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。 + - 暂时缺少骨骼、atlas 或贴图时会显示回退图。资源补齐后,请使用 Eagle 的“刷新缩略图”重新生成当前素材的缩略图。 + - 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。 --- @@ -85,6 +83,8 @@ SpineViewer/ Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。 +Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。自动缩略图只写 Eagle 为当前素材提供的目标,不会隐式改写同组其他素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。 + - 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。 - 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。 @@ -106,7 +106,7 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4 3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。 - **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。 -- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。 +- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览和生成缩略图。 - 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。 - 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。 diff --git a/_locales/de_DE.json b/_locales/de_DE.json index 4ec7b3e..4c49c1e 100644 --- a/_locales/de_DE.json +++ b/_locales/de_DE.json @@ -18,8 +18,8 @@ "pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden", "background": "Hintergrund", "backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt", - "thumbnail": "Vorschaubild", - "thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen", + "thumbnail": "Als aktuelles Vorschaubild setzen", + "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", "versionTitle": "Spine-Version", "reloadTitle": "Ordner neu laden", "animationList": "Animationsliste", @@ -54,6 +54,8 @@ "apiHttp": "Eagle-API meldete HTTP {status}", "noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden", "noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln", + "itemMismatch": "Die Vorschaudatei stimmt nicht mit dem Eagle-Element überein; die Aktualisierung wurde abgebrochen", + "replaceCustomThumbnailConfirm": "Dieses Element hat bereits ein benutzerdefiniertes Vorschaubild. Beim Fortfahren wird es ersetzt; das Plugin kann das vorherige Bild nicht automatisch wiederherstellen. Fortfahren?", "thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}", "imageLoadFailed": "Bild konnte nicht geladen werden", "settingsNotReady": "Asset-Einstellungen sind noch nicht bereit", diff --git a/_locales/en.json b/_locales/en.json index c359493..0d44342 100644 --- a/_locales/en.json +++ b/_locales/en.json @@ -18,8 +18,8 @@ "pmaTitle": "For textures exported with Premultiplied Alpha (PMA)", "background": "Background", "backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes", - "thumbnail": "Set thumbnail", - "thumbnailTitle": "Generate a 400×400 thumbnail from the current frame", + "thumbnail": "Set current item thumbnail", + "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "versionTitle": "Spine version", "reloadTitle": "Reload folder", "animationList": "Animation list", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API returned HTTP {status}", "noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail", "noItemId": "Cannot determine the Eagle item ID from the asset path", + "itemMismatch": "The preview file does not match the Eagle item; the thumbnail update was cancelled", + "replaceCustomThumbnailConfirm": "This item already has a custom thumbnail. Continuing will replace it, and the plugin cannot automatically restore the previous image. Continue?", "thumbnailFailed": "Failed to generate the thumbnail: {error}", "imageLoadFailed": "Failed to load the image", "settingsNotReady": "Asset settings are not ready", diff --git a/_locales/es_ES.json b/_locales/es_ES.json index 630bd8c..fe13211 100644 --- a/_locales/es_ES.json +++ b/_locales/es_ES.json @@ -18,8 +18,8 @@ "pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)", "background": "Fondo", "backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión", - "thumbnail": "Crear miniatura", - "thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual", + "thumbnail": "Usar como miniatura actual", + "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", "versionTitle": "Versión de Spine", "reloadTitle": "Recargar carpeta", "animationList": "Lista de animaciones", @@ -54,6 +54,8 @@ "apiHttp": "La API de Eagle devolvió HTTP {status}", "noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura", "noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta", + "itemMismatch": "El archivo de vista previa no coincide con el elemento de Eagle; se canceló la actualización de la miniatura", + "replaceCustomThumbnailConfirm": "Este elemento ya tiene una miniatura personalizada. Si continúas, se reemplazará y el complemento no podrá restaurar automáticamente la imagen anterior. ¿Continuar?", "thumbnailFailed": "No se pudo generar la miniatura: {error}", "imageLoadFailed": "No se pudo cargar la imagen", "settingsNotReady": "La configuración del recurso aún no está lista", diff --git a/_locales/ja_JP.json b/_locales/ja_JP.json index 874266d..b81364d 100644 --- a/_locales/ja_JP.json +++ b/_locales/ja_JP.json @@ -18,8 +18,8 @@ "pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用", "background": "背景", "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", - "thumbnail": "サムネイル生成", - "thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成", + "thumbnail": "現在の項目のサムネイルに設定", + "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "versionTitle": "Spine バージョン", "reloadTitle": "フォルダーを再読み込み", "animationList": "アニメーション一覧", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API が HTTP {status} を返しました", "noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません", "noItemId": "素材パスから Eagle の項目 ID を特定できません", + "itemMismatch": "プレビューファイルと Eagle の項目情報が一致しないため、サムネイルの更新を中止しました", + "replaceCustomThumbnailConfirm": "この項目には既にカスタムサムネイルがあります。続行すると置き換えられ、以前の画像をプラグインで自動復元できません。続行しますか?", "thumbnailFailed": "サムネイルの生成に失敗しました:{error}", "imageLoadFailed": "画像の読み込みに失敗しました", "settingsNotReady": "素材の設定がまだ準備できていません", diff --git a/_locales/ko_KR.json b/_locales/ko_KR.json index 16f2a77..40dbb7a 100644 --- a/_locales/ko_KR.json +++ b/_locales/ko_KR.json @@ -18,8 +18,8 @@ "pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용", "background": "배경", "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", - "thumbnail": "썸네일 생성", - "thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성", + "thumbnail": "현재 항목 썸네일로 설정", + "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "versionTitle": "Spine 버전", "reloadTitle": "폴더 다시 불러오기", "animationList": "애니메이션 목록", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API가 HTTP {status}를 반환했습니다", "noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다", "noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다", + "itemMismatch": "미리보기 파일과 Eagle 항목 정보가 일치하지 않아 썸네일 업데이트를 취소했습니다", + "replaceCustomThumbnailConfirm": "이 항목에는 이미 사용자 지정 썸네일이 있습니다. 계속하면 교체되며 플러그인에서 이전 이미지를 자동 복원할 수 없습니다. 계속하시겠습니까?", "thumbnailFailed": "썸네일 생성 실패: {error}", "imageLoadFailed": "이미지를 불러오지 못했습니다", "settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다", diff --git a/_locales/ru_RU.json b/_locales/ru_RU.json index 46316f8..cdda9be 100644 --- a/_locales/ru_RU.json +++ b/_locales/ru_RU.json @@ -18,8 +18,8 @@ "pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)", "background": "Фон", "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", - "thumbnail": "Создать миниатюру", - "thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра", + "thumbnail": "Сделать миниатюрой элемента", + "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "versionTitle": "Версия Spine", "reloadTitle": "Перезагрузить папку", "animationList": "Список анимаций", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API вернул HTTP {status}", "noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру", "noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу", + "itemMismatch": "Файл предпросмотра не соответствует элементу Eagle; обновление миниатюры отменено", + "replaceCustomThumbnailConfirm": "У этого элемента уже есть пользовательская миниатюра. При продолжении она будет заменена, и плагин не сможет автоматически восстановить прежнее изображение. Продолжить?", "thumbnailFailed": "Не удалось создать миниатюру: {error}", "imageLoadFailed": "Не удалось загрузить изображение", "settingsNotReady": "Настройки ресурса ещё не готовы", diff --git a/_locales/zh_CN.json b/_locales/zh_CN.json index 885faed..45090c0 100644 --- a/_locales/zh_CN.json +++ b/_locales/zh_CN.json @@ -18,8 +18,8 @@ "pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图", "background": "背景", "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", - "thumbnail": "生成缩略图", - "thumbnailTitle": "使用当前帧生成 400×400 缩略图", + "thumbnail": "设为当前素材缩略图", + "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "versionTitle": "Spine 版本", "reloadTitle": "重新加载目录", "animationList": "动画列表", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API 返回 HTTP {status}", "noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图", "noItemId": "无法从素材路径识别 Eagle 项目 ID", + "itemMismatch": "当前预览文件与 Eagle 素材信息不一致,已取消缩略图更新", + "replaceCustomThumbnailConfirm": "当前素材已有自定义缩略图。继续后将替换它,且插件无法自动恢复原图。是否继续?", "thumbnailFailed": "生成缩略图失败: {error}", "imageLoadFailed": "图片加载失败", "settingsNotReady": "素材设置尚未就绪", diff --git a/_locales/zh_TW.json b/_locales/zh_TW.json index 1cba8b0..d8863f8 100644 --- a/_locales/zh_TW.json +++ b/_locales/zh_TW.json @@ -18,8 +18,8 @@ "pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖", "background": "背景", "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", - "thumbnail": "產生縮圖", - "thumbnailTitle": "使用目前影格產生 400×400 縮圖", + "thumbnail": "設為目前素材縮圖", + "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "versionTitle": "Spine 版本", "reloadTitle": "重新載入目錄", "animationList": "動畫清單", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API 回傳 HTTP {status}", "noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖", "noItemId": "無法從素材路徑辨識 Eagle 項目 ID", + "itemMismatch": "目前預覽檔案與 Eagle 素材資訊不一致,已取消縮圖更新", + "replaceCustomThumbnailConfirm": "目前素材已有自訂縮圖。繼續後將取代它,且外掛無法自動還原原圖。是否繼續?", "thumbnailFailed": "產生縮圖失敗:{error}", "imageLoadFailed": "圖片載入失敗", "settingsNotReady": "素材設定尚未就緒", diff --git a/js/spine-reconcile-worker.js b/js/spine-reconcile-worker.js deleted file mode 100644 index e660029..0000000 --- a/js/spine-reconcile-worker.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { spawn } = require('child_process'); -const utils = require('./spine-utils'); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - -function enqueue(src) { - const library = utils.findLibraryRoot(src); - if (!library) return; // Standalone tests/previews do not need Eagle refresh. - const key = crypto.createHash('sha256').update(__dirname + library).digest('hex'); - const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key); - fs.mkdirSync(directory, { recursive: true }); - const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json'); - const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'; - fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 })); - fs.renameSync(temp, job); - // Electron's Node mode runs the local worker without opening another Eagle. - const child = spawn(process.execPath, [__filename, directory], { - detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } - }); - child.on('error', error => { - try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} - }); - child.unref(); -} - -async function run(directory) { - const lock = path.join(directory, 'worker.lock'); - let fd; - try { fd = fs.openSync(lock, 'wx'); } - catch (error) { - if (error.code !== 'EEXIST') throw error; - let pid; - try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; } - if (!pid) return; // Another worker is still writing its lock. - try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; } - try { fs.unlinkSync(lock); } catch (_) { return; } - return run(directory); - } - fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd); - const snapshots = new Map(); - const nextChecks = new Map(); - const thumbnail = require('../thumbnail/spine'); - try { - let idle = 0; - while (idle < 2) { - await sleep(1000); // Allow the importing callback/window to finish. - const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json')); - if (!jobs.length) { idle++; continue; } - idle = 0; - for (const name of jobs) { - const file = path.join(directory, name); - let raw; - try { - raw = fs.readFileSync(file, 'utf8'); - const job = JSON.parse(raw); - if (Date.now() < (nextChecks.get(name) || 0)) continue; - const remaining = job.deadline - Date.now(); - nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000)); - const result = Date.now() > job.deadline ? { complete: true } - : await thumbnail.reconcileOnce(job.src, snapshots.get(name)); - snapshots.set(name, result.signature); - if (result.complete && fs.readFileSync(file, 'utf8') === raw) { - fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name); - } - } catch (error) { - if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); - } - } - } - } finally { - fs.unlinkSync(lock); - // Cover an enqueue racing the final idle check and lock release. - if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory); - } -} - -module.exports = { enqueue, run }; -if (require.main === module) run(process.argv[2]).catch(error => { - try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} - process.exitCode = 1; -}); diff --git a/js/spine-render.js b/js/spine-render.js index 8721c80..1669814 100644 --- a/js/spine-render.js +++ b/js/spine-render.js @@ -59,7 +59,7 @@ function loadRuntime(dir) { /** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。 * texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。 */ -function buildTextureAtlas(spine, atlasText, dir, texturePaths) { +function buildTextureAtlas(spine, atlasText, texturePaths) { if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); return new Promise((resolve, reject) => { (async () => { @@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) { const textureMap = {}; await Promise.all(pageNames.map(async (name) => { - // 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录 - const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name); - if (!fs.existsSync(p)) return; + // 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。 + // 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。 + const p = texturePaths && texturePaths[name]; + if (!p || !fs.existsSync(p)) return; try { const img = await canvasLib.loadImage(p); textureMap[name] = img; @@ -150,12 +151,11 @@ function getBounds(spine, skeleton) { async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) { if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath); - const dir = path.dirname(skeletonPath); const atlasText = fs.readFileSync(atlasPath, 'utf8'); const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; const spine = loadRuntime(runtimeDir); - const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths); + const atlas = await buildTextureAtlas(spine, atlasText, texturePaths); const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary); if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) { diff --git a/js/spine-utils.js b/js/spine-utils.js index a66f1d4..b3b90f2 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -22,6 +22,80 @@ const JSON_PROBE_BYTES = 256 * 1024; // Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符) const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i; +function unsafeAtlasPath(name, reason) { + const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')'); + error.code = 'UNSAFE_ATLAS_PATH'; + return error; +} + +/** + * Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证, + * 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。 + */ +function validateAtlasPageName(name) { + const raw = String(name == null ? '' : name).trim(); + if (!raw) throw unsafeAtlasPath(name, '路径为空'); + if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符'); + const portable = raw.replace(/\\/g, '/'); + if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) { + throw unsafeAtlasPath(raw, '不允许绝对路径'); + } + if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流'); + const parts = portable.split('/'); + if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段'); + const normalized = path.posix.normalize(portable); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw unsafeAtlasPath(raw, '路径无效'); + } + if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) { + throw unsafeAtlasPath(raw, '不是支持的贴图格式'); + } + return normalized.replace(/^\.\//, ''); +} + +function isPathInside(basePath, targetPath) { + const relative = path.relative(basePath, targetPath); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep)); +} + +function realpath(filePath) { + const implementation = fs.realpathSync.native || fs.realpathSync; + return implementation(filePath); +} + +/** + * 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。 + * realpath 检查会阻止目录内的符号链接跳到允许目录之外。 + */ +function resolveFileWithin(baseDir, relativeName, atlasPage) { + const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName); + const base = path.resolve(baseDir); + const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/')); + if (!isPathInside(base, candidate)) { + if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录'); + return null; + } + if (!fs.existsSync(candidate)) return null; + let realBase; + let realCandidate; + try { + realBase = realpath(base); + realCandidate = realpath(candidate); + } catch (e) { + return null; + } + if (!isPathInside(realBase, realCandidate)) { + if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录'); + return null; + } + try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; } + return candidate; +} + +function resolveAtlasTextureFile(atlasDir, pageName) { + return resolveFileWithin(atlasDir, pageName, true); +} + function extOf(name) { const lower = name.toLowerCase(); for (let i = 0; i < SKELETON_EXTS.length; i++) { @@ -335,12 +409,24 @@ function collectSpineFiles(roots, validFolders) { const seen = new Set(); const out = []; for (const root of roots) { + // Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。 + // 库外仅用于本地测试,仍把当前目录作为唯一边界。 + const probe = path.join(root, '__spineviewer_probe__'); + const libRoot = findLibraryRoot(probe); + if (libRoot) { + try { + const imagesReal = realpath(path.join(libRoot, 'images')); + const rootReal = realpath(root); + if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue; + } catch (e) { continue; } + } let entries; try { entries = fs.readdirSync(root); } catch (e) { continue; } for (const name of entries) { - const full = path.join(root, name); - try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; } if (!extOf(name)) continue; // 只关心 Spine 相关扩展 + // readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。 + const full = resolveFileWithin(root, name, false); + if (!full) continue; // 排除 Eagle 回收站中已删除的项 if (isItemDeleted(full)) continue; if (validFolders && validFolders.size) { @@ -576,15 +662,16 @@ function discoverAssets(filePath, options) { const pageNames = parseAtlasPages(text); const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录 for (const pn of pageNames) { - // 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠) - const inAtlasDir = path.join(atlasDir, pn); - if (fs.existsSync(inAtlasDir)) { + // 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。 + const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn); + if (inAtlasDir) { texturePaths[pn] = inAtlasDir; continue; } - // 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选 - let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); - if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); + // 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。 + const pageFileName = path.posix.basename(pn); + let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase()); + if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName)); let hit = selectBest(cands, atlas.fullPath); if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null; if (hit) texturePaths[pn] = hit.fullPath; @@ -594,22 +681,6 @@ function discoverAssets(filePath, options) { const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem); const skelLower = skeleton ? skeleton.name.toLowerCase() : ''; const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes'); - const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null); - const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0; - const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0; - const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) => - normalizeStem(f.name) === base && - familyOfSkel(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta - ).map((f) => f.fullPath) : []; - const relatedAtlasPaths = atlas ? atlasFiles.filter((f) => - normalizeStem(f.name) === base && - familyOfAtlas(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta - ).map((f) => f.fullPath) : []; - const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))] - .filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp)); - return { skeletonPath: skeleton ? skeleton.fullPath : null, atlasPath: atlas ? atlas.fullPath : null, @@ -621,7 +692,6 @@ function discoverAssets(filePath, options) { atlasCandidates: atlasFiles.map((f) => f.name), atlasCandidatePaths: atlasFiles.map((f) => f.fullPath), textureCandidates: textureFiles.map((f) => f.name), - relatedDataPaths, }; } @@ -633,15 +703,14 @@ function parseAtlasPages(atlasText) { // atlas 中每个页面以一行“xxx.png”之类的文件名开始 for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (/\.(png|jpg|jpeg|webp)$/i.test(line)) { - pages.push(line); + if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) { + pages.push(validateAtlasPageName(line)); } else if (line === '') { // 空白行之后的下一个非空行可能是下一页文件名 continue; } } - // 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等) - return pages.filter((p) => !/[=,:]/.test(p)); + return [...new Set(pages)]; } /** @@ -694,6 +763,8 @@ module.exports = { looksLikeSkeletonJson, discoverAssets, parseAtlasPages, + validateAtlasPageName, + resolveAtlasTextureFile, parseAtlasPma, preferredSkin, applySkin, diff --git a/package.json b/package.json index 5f626b6..fb700ec 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "main": "thumbnail/spine.js", "private": true, "type": "commonjs", + "scripts": { + "check:release": "node tests/release-package.test.js" + }, "dependencies": { "@napi-rs/canvas": "^0.1.66" } diff --git a/release/SpineViewer.eagleplugin b/release/SpineViewer.eagleplugin index 0c9924c..89502bc 100644 Binary files a/release/SpineViewer.eagleplugin and b/release/SpineViewer.eagleplugin differ diff --git a/release/UNIVERSAL_SpineViewer/README.md b/release/UNIVERSAL_SpineViewer/README.md index 051c2d5..cf7d8a9 100644 --- a/release/UNIVERSAL_SpineViewer/README.md +++ b/release/UNIVERSAL_SpineViewer/README.md @@ -9,7 +9,14 @@ Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、 1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。 2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。 3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。 -4. 自动生成带 Spine 标记和版本号的缩略图,也可在预览器中将当前帧设为缩略图。 +4. 自动生成带 Spine 标记和版本号的当前素材缩略图,也可在预览器中主动将当前帧设为当前素材的自定义缩略图。已有自定义缩略图时会先确认;插件不会自动改写同组其他素材。 +5. 若导入时资源尚未配齐,会先显示回退图;补齐后请使用 Eagle 的“刷新缩略图”重新生成。 + +## 安全与隐私 + +- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。跨素材配对只使用当前 Eagle 素材库中已枚举并验证的素材。 +- 自动缩略图只写 Eagle 为当前素材提供的目标,不会改写同组其他素材。手动设置也只作用于当前素材;已有自定义缩略图时会先确认。 +- 诊断日志默认关闭。显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时,日志只写系统临时目录,不写插件目录,也不会进入安装包。 ## 平台与依赖 diff --git a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json index 4ec7b3e..4c49c1e 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json +++ b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json @@ -18,8 +18,8 @@ "pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden", "background": "Hintergrund", "backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt", - "thumbnail": "Vorschaubild", - "thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen", + "thumbnail": "Als aktuelles Vorschaubild setzen", + "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", "versionTitle": "Spine-Version", "reloadTitle": "Ordner neu laden", "animationList": "Animationsliste", @@ -54,6 +54,8 @@ "apiHttp": "Eagle-API meldete HTTP {status}", "noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden", "noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln", + "itemMismatch": "Die Vorschaudatei stimmt nicht mit dem Eagle-Element überein; die Aktualisierung wurde abgebrochen", + "replaceCustomThumbnailConfirm": "Dieses Element hat bereits ein benutzerdefiniertes Vorschaubild. Beim Fortfahren wird es ersetzt; das Plugin kann das vorherige Bild nicht automatisch wiederherstellen. Fortfahren?", "thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}", "imageLoadFailed": "Bild konnte nicht geladen werden", "settingsNotReady": "Asset-Einstellungen sind noch nicht bereit", diff --git a/release/UNIVERSAL_SpineViewer/_locales/en.json b/release/UNIVERSAL_SpineViewer/_locales/en.json index c359493..0d44342 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/en.json +++ b/release/UNIVERSAL_SpineViewer/_locales/en.json @@ -18,8 +18,8 @@ "pmaTitle": "For textures exported with Premultiplied Alpha (PMA)", "background": "Background", "backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes", - "thumbnail": "Set thumbnail", - "thumbnailTitle": "Generate a 400×400 thumbnail from the current frame", + "thumbnail": "Set current item thumbnail", + "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "versionTitle": "Spine version", "reloadTitle": "Reload folder", "animationList": "Animation list", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API returned HTTP {status}", "noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail", "noItemId": "Cannot determine the Eagle item ID from the asset path", + "itemMismatch": "The preview file does not match the Eagle item; the thumbnail update was cancelled", + "replaceCustomThumbnailConfirm": "This item already has a custom thumbnail. Continuing will replace it, and the plugin cannot automatically restore the previous image. Continue?", "thumbnailFailed": "Failed to generate the thumbnail: {error}", "imageLoadFailed": "Failed to load the image", "settingsNotReady": "Asset settings are not ready", diff --git a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json index 630bd8c..fe13211 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json +++ b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json @@ -18,8 +18,8 @@ "pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)", "background": "Fondo", "backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión", - "thumbnail": "Crear miniatura", - "thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual", + "thumbnail": "Usar como miniatura actual", + "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", "versionTitle": "Versión de Spine", "reloadTitle": "Recargar carpeta", "animationList": "Lista de animaciones", @@ -54,6 +54,8 @@ "apiHttp": "La API de Eagle devolvió HTTP {status}", "noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura", "noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta", + "itemMismatch": "El archivo de vista previa no coincide con el elemento de Eagle; se canceló la actualización de la miniatura", + "replaceCustomThumbnailConfirm": "Este elemento ya tiene una miniatura personalizada. Si continúas, se reemplazará y el complemento no podrá restaurar automáticamente la imagen anterior. ¿Continuar?", "thumbnailFailed": "No se pudo generar la miniatura: {error}", "imageLoadFailed": "No se pudo cargar la imagen", "settingsNotReady": "La configuración del recurso aún no está lista", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json index 874266d..b81364d 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json @@ -18,8 +18,8 @@ "pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用", "background": "背景", "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", - "thumbnail": "サムネイル生成", - "thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成", + "thumbnail": "現在の項目のサムネイルに設定", + "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "versionTitle": "Spine バージョン", "reloadTitle": "フォルダーを再読み込み", "animationList": "アニメーション一覧", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API が HTTP {status} を返しました", "noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません", "noItemId": "素材パスから Eagle の項目 ID を特定できません", + "itemMismatch": "プレビューファイルと Eagle の項目情報が一致しないため、サムネイルの更新を中止しました", + "replaceCustomThumbnailConfirm": "この項目には既にカスタムサムネイルがあります。続行すると置き換えられ、以前の画像をプラグインで自動復元できません。続行しますか?", "thumbnailFailed": "サムネイルの生成に失敗しました:{error}", "imageLoadFailed": "画像の読み込みに失敗しました", "settingsNotReady": "素材の設定がまだ準備できていません", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json index 16f2a77..40dbb7a 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json @@ -18,8 +18,8 @@ "pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용", "background": "배경", "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", - "thumbnail": "썸네일 생성", - "thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성", + "thumbnail": "현재 항목 썸네일로 설정", + "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "versionTitle": "Spine 버전", "reloadTitle": "폴더 다시 불러오기", "animationList": "애니메이션 목록", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API가 HTTP {status}를 반환했습니다", "noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다", "noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다", + "itemMismatch": "미리보기 파일과 Eagle 항목 정보가 일치하지 않아 썸네일 업데이트를 취소했습니다", + "replaceCustomThumbnailConfirm": "이 항목에는 이미 사용자 지정 썸네일이 있습니다. 계속하면 교체되며 플러그인에서 이전 이미지를 자동 복원할 수 없습니다. 계속하시겠습니까?", "thumbnailFailed": "썸네일 생성 실패: {error}", "imageLoadFailed": "이미지를 불러오지 못했습니다", "settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json index 46316f8..cdda9be 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json @@ -18,8 +18,8 @@ "pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)", "background": "Фон", "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", - "thumbnail": "Создать миниатюру", - "thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра", + "thumbnail": "Сделать миниатюрой элемента", + "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "versionTitle": "Версия Spine", "reloadTitle": "Перезагрузить папку", "animationList": "Список анимаций", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API вернул HTTP {status}", "noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру", "noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу", + "itemMismatch": "Файл предпросмотра не соответствует элементу Eagle; обновление миниатюры отменено", + "replaceCustomThumbnailConfirm": "У этого элемента уже есть пользовательская миниатюра. При продолжении она будет заменена, и плагин не сможет автоматически восстановить прежнее изображение. Продолжить?", "thumbnailFailed": "Не удалось создать миниатюру: {error}", "imageLoadFailed": "Не удалось загрузить изображение", "settingsNotReady": "Настройки ресурса ещё не готовы", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json index 885faed..45090c0 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json @@ -18,8 +18,8 @@ "pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图", "background": "背景", "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", - "thumbnail": "生成缩略图", - "thumbnailTitle": "使用当前帧生成 400×400 缩略图", + "thumbnail": "设为当前素材缩略图", + "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "versionTitle": "Spine 版本", "reloadTitle": "重新加载目录", "animationList": "动画列表", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API 返回 HTTP {status}", "noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图", "noItemId": "无法从素材路径识别 Eagle 项目 ID", + "itemMismatch": "当前预览文件与 Eagle 素材信息不一致,已取消缩略图更新", + "replaceCustomThumbnailConfirm": "当前素材已有自定义缩略图。继续后将替换它,且插件无法自动恢复原图。是否继续?", "thumbnailFailed": "生成缩略图失败: {error}", "imageLoadFailed": "图片加载失败", "settingsNotReady": "素材设置尚未就绪", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json index 1cba8b0..d8863f8 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json @@ -18,8 +18,8 @@ "pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖", "background": "背景", "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", - "thumbnail": "產生縮圖", - "thumbnailTitle": "使用目前影格產生 400×400 縮圖", + "thumbnail": "設為目前素材縮圖", + "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "versionTitle": "Spine 版本", "reloadTitle": "重新載入目錄", "animationList": "動畫清單", @@ -54,6 +54,8 @@ "apiHttp": "Eagle API 回傳 HTTP {status}", "noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖", "noItemId": "無法從素材路徑辨識 Eagle 項目 ID", + "itemMismatch": "目前預覽檔案與 Eagle 素材資訊不一致,已取消縮圖更新", + "replaceCustomThumbnailConfirm": "目前素材已有自訂縮圖。繼續後將取代它,且外掛無法自動還原原圖。是否繼續?", "thumbnailFailed": "產生縮圖失敗:{error}", "imageLoadFailed": "圖片載入失敗", "settingsNotReady": "素材設定尚未就緒", diff --git a/release/UNIVERSAL_SpineViewer/js/spine-reconcile-worker.js b/release/UNIVERSAL_SpineViewer/js/spine-reconcile-worker.js deleted file mode 100644 index e660029..0000000 --- a/release/UNIVERSAL_SpineViewer/js/spine-reconcile-worker.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { spawn } = require('child_process'); -const utils = require('./spine-utils'); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - -function enqueue(src) { - const library = utils.findLibraryRoot(src); - if (!library) return; // Standalone tests/previews do not need Eagle refresh. - const key = crypto.createHash('sha256').update(__dirname + library).digest('hex'); - const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key); - fs.mkdirSync(directory, { recursive: true }); - const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json'); - const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'; - fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 })); - fs.renameSync(temp, job); - // Electron's Node mode runs the local worker without opening another Eagle. - const child = spawn(process.execPath, [__filename, directory], { - detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } - }); - child.on('error', error => { - try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} - }); - child.unref(); -} - -async function run(directory) { - const lock = path.join(directory, 'worker.lock'); - let fd; - try { fd = fs.openSync(lock, 'wx'); } - catch (error) { - if (error.code !== 'EEXIST') throw error; - let pid; - try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; } - if (!pid) return; // Another worker is still writing its lock. - try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; } - try { fs.unlinkSync(lock); } catch (_) { return; } - return run(directory); - } - fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd); - const snapshots = new Map(); - const nextChecks = new Map(); - const thumbnail = require('../thumbnail/spine'); - try { - let idle = 0; - while (idle < 2) { - await sleep(1000); // Allow the importing callback/window to finish. - const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json')); - if (!jobs.length) { idle++; continue; } - idle = 0; - for (const name of jobs) { - const file = path.join(directory, name); - let raw; - try { - raw = fs.readFileSync(file, 'utf8'); - const job = JSON.parse(raw); - if (Date.now() < (nextChecks.get(name) || 0)) continue; - const remaining = job.deadline - Date.now(); - nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000)); - const result = Date.now() > job.deadline ? { complete: true } - : await thumbnail.reconcileOnce(job.src, snapshots.get(name)); - snapshots.set(name, result.signature); - if (result.complete && fs.readFileSync(file, 'utf8') === raw) { - fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name); - } - } catch (error) { - if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); - } - } - } - } finally { - fs.unlinkSync(lock); - // Cover an enqueue racing the final idle check and lock release. - if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory); - } -} - -module.exports = { enqueue, run }; -if (require.main === module) run(process.argv[2]).catch(error => { - try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {} - process.exitCode = 1; -}); diff --git a/release/UNIVERSAL_SpineViewer/js/spine-render.js b/release/UNIVERSAL_SpineViewer/js/spine-render.js index 8721c80..1669814 100644 --- a/release/UNIVERSAL_SpineViewer/js/spine-render.js +++ b/release/UNIVERSAL_SpineViewer/js/spine-render.js @@ -59,7 +59,7 @@ function loadRuntime(dir) { /** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。 * texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。 */ -function buildTextureAtlas(spine, atlasText, dir, texturePaths) { +function buildTextureAtlas(spine, atlasText, texturePaths) { if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); return new Promise((resolve, reject) => { (async () => { @@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) { const textureMap = {}; await Promise.all(pageNames.map(async (name) => { - // 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录 - const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name); - if (!fs.existsSync(p)) return; + // 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。 + // 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。 + const p = texturePaths && texturePaths[name]; + if (!p || !fs.existsSync(p)) return; try { const img = await canvasLib.loadImage(p); textureMap[name] = img; @@ -150,12 +151,11 @@ function getBounds(spine, skeleton) { async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) { if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath); - const dir = path.dirname(skeletonPath); const atlasText = fs.readFileSync(atlasPath, 'utf8'); const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; const spine = loadRuntime(runtimeDir); - const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths); + const atlas = await buildTextureAtlas(spine, atlasText, texturePaths); const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary); if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) { diff --git a/release/UNIVERSAL_SpineViewer/js/spine-utils.js b/release/UNIVERSAL_SpineViewer/js/spine-utils.js index a66f1d4..b3b90f2 100644 --- a/release/UNIVERSAL_SpineViewer/js/spine-utils.js +++ b/release/UNIVERSAL_SpineViewer/js/spine-utils.js @@ -22,6 +22,80 @@ const JSON_PROBE_BYTES = 256 * 1024; // Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符) const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i; +function unsafeAtlasPath(name, reason) { + const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')'); + error.code = 'UNSAFE_ATLAS_PATH'; + return error; +} + +/** + * Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证, + * 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。 + */ +function validateAtlasPageName(name) { + const raw = String(name == null ? '' : name).trim(); + if (!raw) throw unsafeAtlasPath(name, '路径为空'); + if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符'); + const portable = raw.replace(/\\/g, '/'); + if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) { + throw unsafeAtlasPath(raw, '不允许绝对路径'); + } + if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流'); + const parts = portable.split('/'); + if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段'); + const normalized = path.posix.normalize(portable); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw unsafeAtlasPath(raw, '路径无效'); + } + if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) { + throw unsafeAtlasPath(raw, '不是支持的贴图格式'); + } + return normalized.replace(/^\.\//, ''); +} + +function isPathInside(basePath, targetPath) { + const relative = path.relative(basePath, targetPath); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep)); +} + +function realpath(filePath) { + const implementation = fs.realpathSync.native || fs.realpathSync; + return implementation(filePath); +} + +/** + * 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。 + * realpath 检查会阻止目录内的符号链接跳到允许目录之外。 + */ +function resolveFileWithin(baseDir, relativeName, atlasPage) { + const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName); + const base = path.resolve(baseDir); + const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/')); + if (!isPathInside(base, candidate)) { + if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录'); + return null; + } + if (!fs.existsSync(candidate)) return null; + let realBase; + let realCandidate; + try { + realBase = realpath(base); + realCandidate = realpath(candidate); + } catch (e) { + return null; + } + if (!isPathInside(realBase, realCandidate)) { + if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录'); + return null; + } + try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; } + return candidate; +} + +function resolveAtlasTextureFile(atlasDir, pageName) { + return resolveFileWithin(atlasDir, pageName, true); +} + function extOf(name) { const lower = name.toLowerCase(); for (let i = 0; i < SKELETON_EXTS.length; i++) { @@ -335,12 +409,24 @@ function collectSpineFiles(roots, validFolders) { const seen = new Set(); const out = []; for (const root of roots) { + // Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。 + // 库外仅用于本地测试,仍把当前目录作为唯一边界。 + const probe = path.join(root, '__spineviewer_probe__'); + const libRoot = findLibraryRoot(probe); + if (libRoot) { + try { + const imagesReal = realpath(path.join(libRoot, 'images')); + const rootReal = realpath(root); + if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue; + } catch (e) { continue; } + } let entries; try { entries = fs.readdirSync(root); } catch (e) { continue; } for (const name of entries) { - const full = path.join(root, name); - try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; } if (!extOf(name)) continue; // 只关心 Spine 相关扩展 + // readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。 + const full = resolveFileWithin(root, name, false); + if (!full) continue; // 排除 Eagle 回收站中已删除的项 if (isItemDeleted(full)) continue; if (validFolders && validFolders.size) { @@ -576,15 +662,16 @@ function discoverAssets(filePath, options) { const pageNames = parseAtlasPages(text); const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录 for (const pn of pageNames) { - // 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠) - const inAtlasDir = path.join(atlasDir, pn); - if (fs.existsSync(inAtlasDir)) { + // 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。 + const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn); + if (inAtlasDir) { texturePaths[pn] = inAtlasDir; continue; } - // 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选 - let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); - if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); + // 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。 + const pageFileName = path.posix.basename(pn); + let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase()); + if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName)); let hit = selectBest(cands, atlas.fullPath); if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null; if (hit) texturePaths[pn] = hit.fullPath; @@ -594,22 +681,6 @@ function discoverAssets(filePath, options) { const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem); const skelLower = skeleton ? skeleton.name.toLowerCase() : ''; const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes'); - const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null); - const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0; - const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0; - const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) => - normalizeStem(f.name) === base && - familyOfSkel(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta - ).map((f) => f.fullPath) : []; - const relatedAtlasPaths = atlas ? atlasFiles.filter((f) => - normalizeStem(f.name) === base && - familyOfAtlas(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta - ).map((f) => f.fullPath) : []; - const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))] - .filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp)); - return { skeletonPath: skeleton ? skeleton.fullPath : null, atlasPath: atlas ? atlas.fullPath : null, @@ -621,7 +692,6 @@ function discoverAssets(filePath, options) { atlasCandidates: atlasFiles.map((f) => f.name), atlasCandidatePaths: atlasFiles.map((f) => f.fullPath), textureCandidates: textureFiles.map((f) => f.name), - relatedDataPaths, }; } @@ -633,15 +703,14 @@ function parseAtlasPages(atlasText) { // atlas 中每个页面以一行“xxx.png”之类的文件名开始 for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (/\.(png|jpg|jpeg|webp)$/i.test(line)) { - pages.push(line); + if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) { + pages.push(validateAtlasPageName(line)); } else if (line === '') { // 空白行之后的下一个非空行可能是下一页文件名 continue; } } - // 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等) - return pages.filter((p) => !/[=,:]/.test(p)); + return [...new Set(pages)]; } /** @@ -694,6 +763,8 @@ module.exports = { looksLikeSkeletonJson, discoverAssets, parseAtlasPages, + validateAtlasPageName, + resolveAtlasTextureFile, parseAtlasPma, preferredSkin, applySkin, diff --git a/release/UNIVERSAL_SpineViewer/thumbnail/spine.js b/release/UNIVERSAL_SpineViewer/thumbnail/spine.js index 03a717c..22e5fee 100644 --- a/release/UNIVERSAL_SpineViewer/thumbnail/spine.js +++ b/release/UNIVERSAL_SpineViewer/thumbnail/spine.js @@ -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; diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index 9fac87a..93a1acc 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -25,7 +25,7 @@ 背景
- + @@ -64,6 +64,8 @@ // 使用 Node 集成提供的模块 const path = require('path'); const fs = require('fs'); + const os = require('os'); + const crypto = require('crypto'); const utils = require('../js/spine-utils.js'); const spineBadge = require('../js/spine-badge.js'); const clippingRenderer = require('../js/spine-canvas-clipping.js'); @@ -95,10 +97,13 @@ } } - // 简易日志(写入插件目录 thumbnails.log,便于诊断) + // 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 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 */ } } @@ -477,14 +482,22 @@ return match ? match[1] : null; } - async function setEagleCustomThumbnail(itemId, thumbnailPath) { - if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) { - const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId)); - const info = await response.json(); - if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath)) - throw new Error(t('viewer.compoundName')); - if (info.data.ext === 'txt') throw new Error(t('viewer.txtRegistered')); + async function getCurrentEagleItem() { + const pathItemId = eagleItemIdFromPath(filePath); + const urlItemId = urlParams.get('id'); + if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch')); + const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId)); + let result = null; + try { result = await response.json(); } catch (e) { /* use HTTP status below */ } + if (!response.ok || !result || result.status !== 'success' || !result.data) { + throw new Error(t('viewer.apiHttp', { status: response.status })); } + if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch')); + if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered')); + return { id: pathItemId, data: result.data }; + } + + async function setEagleCustomThumbnail(itemId, thumbnailPath) { const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -503,8 +516,10 @@ if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; } const thumbBtn = document.getElementById('thumbBtn'); thumbBtn.disabled = true; - setStatus(t('viewer.generating'), Infinity); try { + const currentItem = await getCurrentEagleItem(); + if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return; + setStatus(t('viewer.generating'), Infinity); // 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼 playing = false; updatePlayButton(); @@ -547,25 +562,14 @@ const isWebp = out.startsWith('data:image/webp'); if (!isWebp) out = off.toDataURL('image/png'); const buf = Buffer.from(out.split(',')[1], 'base64'); - const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, ''); - const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath); - const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png'); - const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png')); + const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png')); try { - if (!itemId) throw new Error(t('viewer.noItemId')); fs.writeFileSync(tempPath, buf); - await setEagleCustomThumbnail(itemId, tempPath); + await setEagleCustomThumbnail(currentItem.id, tempPath); setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500); - log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)'); + log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)'); } catch (e) { - // 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。 - try { - fs.writeFileSync(outPath, buf); - setStatus(t('viewer.thumbWrittenNoRefresh', { error: (e && e.message) || String(e) }), 6000); - log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e))); - } catch (e2) { - setStatus(t('viewer.thumbnailFailed', { error: (e2 && e2.message) || String(e2) }), 6000); - } + setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000); } finally { try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ } } diff --git a/tests/official-examples.test.js b/tests/official-examples.test.js index 82117a8..8fb87bc 100644 --- a/tests/official-examples.test.js +++ b/tests/official-examples.test.js @@ -10,8 +10,11 @@ const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures')); const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas'); if(!fs.existsSync(atlasPath))continue; const text=fs.readFileSync(atlasPath,'utf8'); - for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page); - const atlas=await render.buildTextureAtlas(spine,text,dir); + const texturePaths={}; + for(const page of utils.parseAtlasPages(text)){ + const texture=utils.resolveAtlasTextureFile(dir,page);assert(texture,version+'/'+name+' missing '+page);texturePaths[page]=texture; + } + const atlas=await render.buildTextureAtlas(spine,text,texturePaths); for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){ const source=path.join(dir,file),detected=utils.detectVersion(source); assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version'); diff --git a/tests/reconcile-lifecycle.test.js b/tests/reconcile-lifecycle.test.js deleted file mode 100644 index d85a676..0000000 --- a/tests/reconcile-lifecycle.test.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; -const assert = require('assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { execFile } = require('child_process'); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-worker-lifecycle-')); -const library = path.join(root, 'test.library'); -const source = path.join(library, 'images', 'test.info', 'hero.atlas.txt'); -const marker = path.join(root, 'completed'); -const worker = path.resolve(__dirname, '../js/spine-reconcile-worker.js'); -const thumbnail = path.resolve(__dirname, '../thumbnail/spine.js'); -const key = crypto.createHash('sha256').update(path.dirname(worker) + library).digest('hex'); -const queue = path.join(os.tmpdir(), 'spineviewer-reconcile', key); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); -(async () => { - try { - fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'atlas'); - fs.writeFileSync(path.join(library, 'metadata.json'), '{"folders":[]}'); - // Isolate lifecycle behavior from rendering and the user's running Eagle API. - const preload = path.join(root, 'preload.cjs'); - fs.writeFileSync(preload, `require.cache[${JSON.stringify(thumbnail)}] = { exports: { - reconcileOnce: async (src, previous) => { - if (previous !== 'stable') return { signature: 'stable', complete: false }; - require('fs').appendFileSync(${JSON.stringify(marker)}, String(process.pid) + '\\n'); - return { complete: true }; - } - }};`); - await new Promise((resolve, reject) => execFile(process.execPath, ['-e', `require(${JSON.stringify(worker)}).enqueue(${JSON.stringify(source)})`], - { env: { ...process.env, NODE_OPTIONS: '--require=' + preload } }, error => error ? reject(error) : resolve())); - assert(!fs.existsSync(marker), 'launcher returns before reconciliation'); - const deadline = Date.now() + 15000; - while ((!fs.existsSync(marker) || fs.existsSync(path.join(queue, 'worker.lock'))) && Date.now() < deadline) await sleep(100); - assert(fs.existsSync(marker), 'worker completes after launching process has exited'); - assert(!fs.existsSync(path.join(queue, 'worker.lock')), 'worker releases lock and exits when idle'); - assert.equal(fs.readdirSync(queue).filter(name => name.endsWith('.json')).length, 0); - console.log('PASS: detached worker survives callback process exit, completes queued work and releases lock'); - } finally { - if (!fs.existsSync(path.join(queue, 'worker.lock'))) { - fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(queue, { recursive: true, force: true }); - } - } -})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/release-package.test.js b/tests/release-package.test.js new file mode 100644 index 0000000..c8295a2 --- /dev/null +++ b/tests/release-package.test.js @@ -0,0 +1,43 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const releaseDir = path.join(root, 'release', 'UNIVERSAL_SpineViewer'); +const archive = path.join(root, 'release', 'SpineViewer.eagleplugin'); + +function walk(directory, prefix = '') { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relative = path.posix.join(prefix, entry.name); + if (entry.isDirectory()) files.push(...walk(path.join(directory, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function zipEntries(file) { + const data = fs.readFileSync(file); + const entries = []; + for (let offset = 0; offset + 46 <= data.length;) { + const header = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]), offset); + if (header < 0) break; + const nameLength = data.readUInt16LE(header + 28); + const extraLength = data.readUInt16LE(header + 30); + const commentLength = data.readUInt16LE(header + 32); + entries.push(data.subarray(header + 46, header + 46 + nameLength).toString('utf8')); + offset = header + 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +for (const [label, files] of [ + ['release directory', walk(releaseDir)], + ['eagleplugin archive', zipEntries(archive)], +]) { + assert.equal(files.some((file) => /\.log$/i.test(file)), false, label + ' must not contain runtime or historical logs'); + assert.equal(files.some((file) => /(^|\/)\.DS_Store$/i.test(file)), false, label + ' must not contain macOS metadata'); +} + +console.log('PASS: release directory and .eagleplugin contain no logs or .DS_Store files'); diff --git a/tests/security-privacy.test.js b/tests/security-privacy.test.js new file mode 100644 index 0000000..b9addf1 --- /dev/null +++ b/tests/security-privacy.test.js @@ -0,0 +1,77 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const utils = require('../js/spine-utils'); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-security-')); +try { + for (const unsafe of [ + '../../../secret.png', + '..\\..\\secret.png', + '/tmp/secret.png', + 'C:\\secret.png', + 'C:secret.png', + '\\\\server\\share\\secret.png', + 'file:///tmp/secret.png', + 'folder/../secret.png', + 'folder\\..\\secret.png', + 'bad\0name.png', + ]) { + assert.throws(() => utils.validateAtlasPageName(unsafe), (error) => error && error.code === 'UNSAFE_ATLAS_PATH', unsafe); + } + assert.equal(utils.validateAtlasPageName('./textures/page.png'), 'textures/page.png'); + + const atlasDir = path.join(root, 'atlas'); + fs.mkdirSync(path.join(atlasDir, 'textures'), { recursive: true }); + const nestedTexture = path.join(atlasDir, 'textures', 'page.png'); + fs.writeFileSync(nestedTexture, 'texture'); + assert.equal(utils.resolveAtlasTextureFile(atlasDir, 'textures/page.png'), nestedTexture); + + const outside = path.join(root, 'outside.png'); + const link = path.join(atlasDir, 'linked.png'); + fs.writeFileSync(outside, 'private'); + try { + fs.symlinkSync(outside, link); + assert.throws(() => utils.resolveAtlasTextureFile(atlasDir, 'linked.png'), (error) => error && error.code === 'UNSAFE_ATLAS_PATH'); + } catch (error) { + if (!['EPERM', 'EACCES'].includes(error.code)) throw error; + } + + const library = path.join(root, 'safe.library'); + const images = path.join(library, 'images'); + fs.mkdirSync(images, { recursive: true }); + fs.writeFileSync(path.join(library, 'metadata.json'), JSON.stringify({ folders: [{ id: 'spine', name: 'Spine', children: [] }] })); + function item(id, name, contents) { + const dir = path.join(images, id + '.info'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({ id, name: path.parse(name).name, ext: path.extname(name).slice(1), folders: ['spine'], isDeleted: false })); + const file = path.join(dir, name); + fs.writeFileSync(file, contents); + return file; + } + const skeleton = item('SKELETON', 'hero.skel', Buffer.from('\0' + '3.8.1\0')); + item('ATLAS', 'hero.atlas', 'textures/hero.png\nsize: 64,64\n'); + const texture = item('TEXTURE', 'hero.png', 'texture'); + const assets = utils.discoverAssets(skeleton, { strictStem: true, refreshIndex: true }); + assert.equal(assets.texturePaths['textures/hero.png'], texture, 'cross-item texture must come from a validated Eagle item'); + + const maliciousDir = path.join(root, 'malicious'); + fs.mkdirSync(maliciousDir); + const maliciousSkeleton = path.join(maliciousDir, 'evil.skel'); + fs.writeFileSync(maliciousSkeleton, Buffer.from('\0' + '3.8.1\0')); + fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n'); + assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH'); + + const thumbnailSource = fs.readFileSync(path.join(__dirname, '../thumbnail/spine.js'), 'utf8'); + assert.doesNotMatch(thumbnailSource, /setCustomThumbnail|refreshRelatedThumbnails|spine-reconcile-worker|relatedDataPaths/); + assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false); + const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8'); + assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/); + assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/); + assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/); + console.log('PASS: atlas traversal, absolute paths, symlink escape, validated Eagle pairing, and no automatic cross-item thumbnail writes'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/tests/thumbnail-compound.test.js b/tests/thumbnail-compound.test.js index 90065cf..31f4bb8 100644 --- a/tests/thumbnail-compound.test.js +++ b/tests/thumbnail-compound.test.js @@ -7,7 +7,7 @@ const { normalizeItem } = require('../js/eagle-compound'); const generate = require('../thumbnail/spine'); const { canvasLib } = require('../js/spine-render'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-')); -const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy')); +const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '34-43/3.4/spineboy')); (async () => { try { diff --git a/tests/viewer-i18n.test.js b/tests/viewer-i18n.test.js index 8fd3d32..da00316 100644 --- a/tests/viewer-i18n.test.js +++ b/tests/viewer-i18n.test.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { createRequire } = require('node:module'); -const roots = ['.', 'release/MAC_arm64_SpineViewer', 'release/WIN_x64_SpineViewer']; +const roots = ['.', 'release/UNIVERSAL_SpineViewer']; for (const root of roots) { const html = fs.readFileSync(path.join(root, 'viewer/spine.html'), 'utf8'); const script = html.match(/