From c528a3ad45222a15dc3565f62da871fb7adfa806 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 11:39:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(format-converter):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E6=88=AA=E5=9B=BE=E5=AF=BC=E5=85=A5=E3=80=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=96=E6=8B=BD=E4=B8=8E=20pluginData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 ZTools 3.2 截图编辑态、文件外拖和插件数据目录,同时保留 2.4–3.1 兼容回退。 AI-Co-Authored-By: Codex --- plugins/format-converter/CHANGELOG.md | 7 + plugins/format-converter/README.md | 7 + plugins/format-converter/package.json | 2 +- plugins/format-converter/plugin.json | 2 +- .../preload/capture-temp-store.cjs | 304 ++++++++++++++++++ .../preload/host-compatibility.cjs | 89 +++++ .../format-converter/preload/job-manager.cjs | 1 + plugins/format-converter/preload/package.json | 2 +- .../preload/screen-capture.cjs | 19 ++ plugins/format-converter/preload/services.cjs | 99 +++++- .../format-converter/scripts/verify-dist.mjs | 2 +- plugins/format-converter/src/App.tsx | 35 +- plugins/format-converter/src/types.ts | 7 + .../tests/preload/capture-temp-store.test.cjs | 186 +++++++++++ .../tests/preload/host-compatibility.test.cjs | 57 ++++ .../tests/preload/job-manager.test.cjs | 16 + .../tests/preload/screen-capture.test.cjs | 28 ++ .../tests/preload/services.test.cjs | 142 +++++++- 18 files changed, 991 insertions(+), 14 deletions(-) create mode 100644 plugins/format-converter/preload/capture-temp-store.cjs create mode 100644 plugins/format-converter/preload/host-compatibility.cjs create mode 100644 plugins/format-converter/preload/screen-capture.cjs create mode 100644 plugins/format-converter/tests/preload/capture-temp-store.test.cjs create mode 100644 plugins/format-converter/tests/preload/host-compatibility.test.cjs create mode 100644 plugins/format-converter/tests/preload/screen-capture.test.cjs diff --git a/plugins/format-converter/CHANGELOG.md b/plugins/format-converter/CHANGELOG.md index b7c015811..bf53f5c55 100644 --- a/plugins/format-converter/CHANGELOG.md +++ b/plugins/format-converter/CHANGELOG.md @@ -1,5 +1,12 @@ # 更新日志 +## 0.1.1 - 2026-09-01 + +- 适配 ZTools 3.2:截图导入、转换结果外拖,以及 runtime 使用 `pluginData` 专属目录。 +- runtime 迁移保留旧 `userData` 副本,ZTools 2.4–3.1 继续从原路径工作。 +- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务初始化前显示升级提示,仅浏览器开发预览放行。 +- 输出外拖拒绝符号链接、非普通文件及真实路径逃逸授权目录的文件,并只向宿主传递规范化真实路径。 + ## 0.1.0 - 2026-07-29 - 首次提供通用格式转换中心和批量作业队列。 diff --git a/plugins/format-converter/README.md b/plugins/format-converter/README.md index 9a92842f1..49d8c0139 100644 --- a/plugins/format-converter/README.md +++ b/plugins/format-converter/README.md @@ -2,6 +2,13 @@ 面向 ZTools 的本地批量格式转换中心。支持 Word、Excel、PowerPoint、PDF、图片、文本和常见数据格式,并明确区分视觉保真、可编辑重建和内容提取三类路线。 +## ZTools 兼容性 + +- ZTools 3.2.0:支持截图导入、转换输出外拖,并将可重建 runtime 优先放入 `pluginData`。 +- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。 +- runtime 迁移为复制后保留旧目录,不会在升级时删除旧数据,支持临时降级宿主。 +- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 + ## 首发范围 - 图片:PNG、JPEG、WebP、AVIF、TIFF、GIF、BMP 的读取与常见格式输出。 diff --git a/plugins/format-converter/package.json b/plugins/format-converter/package.json index cf35ef2aa..399d376af 100644 --- a/plugins/format-converter/package.json +++ b/plugins/format-converter/package.json @@ -1,6 +1,6 @@ { "name": "ztools-format-converter", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "description": "Cross-platform batch format conversion workbench for ZTools.", diff --git a/plugins/format-converter/plugin.json b/plugins/format-converter/plugin.json index a0c755b72..7128bbdbc 100644 --- a/plugins/format-converter/plugin.json +++ b/plugins/format-converter/plugin.json @@ -3,7 +3,7 @@ "title": "格式转换", "description": "Word、Excel、PPT、PDF、图片、文本与常见数据格式的本地批量转换中心。", "author": "harris", - "version": "0.1.0", + "version": "0.1.1", "main": "index.html", "preload": "preload/services.cjs", "logo": "logo.svg", diff --git a/plugins/format-converter/preload/capture-temp-store.cjs b/plugins/format-converter/preload/capture-temp-store.cjs new file mode 100644 index 000000000..a3a3111ee --- /dev/null +++ b/plugins/format-converter/preload/capture-temp-store.cjs @@ -0,0 +1,304 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const fssync = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const DEFAULT_MAX_CAPTURE_BYTES = 64 * 1024 ** 2; +const activeStores = new Set(); +let processCleanupRegistered = false; + +function captureError(code, message, cause) { + const error = new Error(message, cause ? { cause } : undefined); + error.code = code; + return error; +} + +function parseCaptureDataUrl(dataUrl, maxBytes = DEFAULT_MAX_CAPTURE_BYTES) { + if (typeof dataUrl !== "string") throw captureError("SCREEN_CAPTURE_INVALID", "截图格式不受支持。"); + const header = /^data:image\/(png|jpeg|jpg|webp);base64,/i.exec(dataUrl); + if (!header) throw captureError("SCREEN_CAPTURE_INVALID", "截图格式不受支持。"); + const payload = dataUrl.slice(header[0].length); + const maxBase64Length = Math.ceil(maxBytes / 3) * 4; + if (payload.length > maxBase64Length) throw captureError("SCREEN_CAPTURE_TOO_LARGE", `截图不能超过 ${Math.round(maxBytes / 1024 ** 2)} MB。`); + if (!payload || payload.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(payload)) { + throw captureError("SCREEN_CAPTURE_INVALID", "截图数据不是有效的 Base64 图像。"); + } + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + const decodedBytes = payload.length / 4 * 3 - padding; + if (decodedBytes <= 0 || decodedBytes > maxBytes) throw captureError("SCREEN_CAPTURE_TOO_LARGE", `截图不能超过 ${Math.round(maxBytes / 1024 ** 2)} MB。`); + const buffer = Buffer.from(payload, "base64"); + if (buffer.length !== decodedBytes) throw captureError("SCREEN_CAPTURE_INVALID", "截图数据不是有效的 Base64 图像。"); + return { buffer, extension: header[1].toLowerCase() === "jpg" ? "jpeg" : header[1].toLowerCase() }; +} + +function registerProcessCleanup() { + if (processCleanupRegistered || typeof process?.once !== "function") return; + processCleanupRegistered = true; + process.once("exit", () => { + for (const store of [...activeStores]) store.cleanupAllSync(); + }); +} + +function createCaptureTempStore(options = {}) { + const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_CAPTURE_BYTES; + const now = options.now || Date.now; + const randomId = options.randomId || (() => crypto.randomUUID()); + const tempDirectory = options.tempDirectory || os.tmpdir(); + const trackedPaths = new Set(); + const grants = new Map(); + let root = null; + let rootPromise = null; + let generation = 0; + + function rootIsSafeSync(candidate) { + if (!candidate || path.dirname(candidate) === candidate) return false; + try { + const stat = fssync.lstatSync(candidate); + return stat.isDirectory() && !stat.isSymbolicLink() && fssync.realpathSync(candidate) === candidate; + } catch { return false; } + } + + function removeRootIfEmptySync() { + if (!root || trackedPaths.size) return; + const candidate = root; + try { + const stat = fssync.lstatSync(candidate); + if (stat.isSymbolicLink()) fssync.unlinkSync(candidate); + else if (stat.isDirectory()) fssync.rmdirSync(candidate); + } catch {} + if (!fssync.existsSync(candidate)) { + root = null; + activeStores.delete(store); + } + } + + function clearGrantRecord(grantId) { + const grant = grants.get(grantId); + if (!grant) return null; + grants.delete(grantId); + if (grant.timer) clearTimeout(grant.timer); + return grant; + } + + function cleanupTrackedPathSync(filePath) { + if (!trackedPaths.has(filePath)) return false; + if (!rootIsSafeSync(root)) { + cleanupAllSync(); + return true; + } + if (path.dirname(filePath) !== root) { + trackedPaths.delete(filePath); + return false; + } + try { + const stat = fssync.lstatSync(filePath); + if (stat.isFile() || stat.isSymbolicLink()) fssync.unlinkSync(filePath); + } catch {} + trackedPaths.delete(filePath); + removeRootIfEmptySync(); + return true; + } + + function cleanupGrantSync(grantId) { + const grant = clearGrantRecord(grantId); + if (!grant) return false; + return cleanupTrackedPathSync(grant.filePath); + } + + function cleanupAllSync() { + generation += 1; + for (const grantId of [...grants.keys()]) clearGrantRecord(grantId); + const candidate = root; + if (!candidate) { + trackedPaths.clear(); + activeStores.delete(store); + return; + } + if (!rootIsSafeSync(candidate)) { + try { if (fssync.lstatSync(candidate).isSymbolicLink()) fssync.unlinkSync(candidate); } catch {} + trackedPaths.clear(); + root = null; + activeStores.delete(store); + return; + } + for (const filePath of [...trackedPaths]) { + if (path.dirname(filePath) !== candidate) continue; + try { + const stat = fssync.lstatSync(filePath); + if (stat.isFile() || stat.isSymbolicLink()) fssync.unlinkSync(filePath); + } catch {} + } + trackedPaths.clear(); + try { fssync.rmdirSync(candidate); } catch {} + if (!fssync.existsSync(candidate)) root = null; + activeStores.delete(store); + } + + function cleanupInactiveSync() { + generation += 1; + const currentTime = now(); + const retainedPaths = new Set(); + for (const [grantId, grant] of [...grants]) { + const retryable = grant.jobId !== null && grant.expiresAt > currentTime; + if (grant.active || retryable) retainedPaths.add(grant.filePath); + else cleanupGrantSync(grantId); + } + for (const filePath of [...trackedPaths]) if (!retainedPaths.has(filePath)) cleanupTrackedPathSync(filePath); + } + + async function assertSafeRoot(candidate) { + const stat = await fs.lstat(candidate).catch(() => null); + if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录已失效,请重试。"); + const [canonicalRoot, canonicalTemp] = await Promise.all([fs.realpath(candidate), fs.realpath(tempDirectory)]); + if (canonicalRoot !== candidate || path.dirname(canonicalRoot) !== canonicalTemp) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录越过了安全边界。"); + } + return canonicalRoot; + } + + async function ensureRoot() { + if (root) return assertSafeRoot(root); + if (rootPromise) return rootPromise; + const expectedGeneration = generation; + rootPromise = (async () => { + const canonicalTemp = await fs.realpath(tempDirectory); + const created = await fs.mkdtemp(path.join(canonicalTemp, `ztools-format-converter-${process.pid}-`)); + try { + await fs.chmod(created, 0o700); + const canonicalRoot = await fs.realpath(created); + const stat = await fs.lstat(created); + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时目录已清理。"); + if (stat.isSymbolicLink() || !stat.isDirectory() || canonicalRoot !== created || path.dirname(canonicalRoot) !== canonicalTemp) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时目录越过了安全边界。"); + } + root = canonicalRoot; + activeStores.add(store); + registerProcessCleanup(); + return root; + } catch (error) { + try { await fs.rmdir(created); } catch {} + throw error; + } + })(); + try { return await rootPromise; } + finally { rootPromise = null; } + } + + async function createFromDataUrl(dataUrl) { + const { buffer, extension } = parseCaptureDataUrl(dataUrl, maxBytes); + const expectedGeneration = generation; + const safeRoot = await ensureRoot(); + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时目录已清理。"); + const id = String(randomId()); + if (!/^[A-Za-z0-9-]{1,128}$/.test(id)) throw captureError("CAPTURE_TEMP_WRITE_FAILED", "无法生成安全的截图临时文件名。"); + const output = path.join(safeRoot, `capture-${id}.${extension}`); + if (path.dirname(output) !== safeRoot) throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时文件越过了安全边界。"); + let handle; + let created = false; + try { + await assertSafeRoot(safeRoot); + const flags = fssync.constants.O_WRONLY | fssync.constants.O_CREAT | fssync.constants.O_EXCL | (fssync.constants.O_NOFOLLOW || 0); + handle = await fs.open(output, flags, 0o600); + created = true; + await handle.writeFile(buffer); + await handle.chmod(0o600); + await handle.sync(); + await handle.close(); + handle = null; + if (generation !== expectedGeneration) throw captureError("CAPTURE_TEMP_CLEANED", "插件已退出,截图临时文件已清理。"); + const [stat, canonicalFile, canonicalParent] = await Promise.all([fs.lstat(output), fs.realpath(output), fs.realpath(path.dirname(output))]); + if (stat.isSymbolicLink() || !stat.isFile() || canonicalParent !== safeRoot || path.dirname(canonicalFile) !== safeRoot || canonicalFile !== output) { + throw captureError("CAPTURE_TEMP_BOUNDARY", "截图临时文件越过了安全边界。"); + } + trackedPaths.add(canonicalFile); + return { path: canonicalFile, size: stat.size }; + } catch (error) { + try { await handle?.close(); } catch {} + if (created) await fs.unlink(output).catch(() => undefined); + removeRootIfEmptySync(); + if (error?.code === "EEXIST" || error?.code === "ELOOP") throw captureError("CAPTURE_TEMP_CONFLICT", "截图临时文件已被占用,请重试。", error); + if (error?.code) throw error; + throw captureError("CAPTURE_TEMP_WRITE_FAILED", "无法安全写入截图临时文件。", error); + } + } + + function scheduleExpiry(grantId) { + const grant = grants.get(grantId); + if (!grant) return; + const delay = Math.max(0, Math.min(2_147_483_647, grant.expiresAt - now() + 1)); + grant.timer = setTimeout(() => { + const current = grants.get(grantId); + if (!current) return; + if (current.expiresAt <= now() && current.active) { + current.expired = true; + current.timer = null; + } else if (current.expiresAt <= now()) cleanupGrantSync(grantId); + else scheduleExpiry(grantId); + }, delay); + grant.timer.unref?.(); + } + + function associateGrant(grantId, filePath, expiresAt) { + if (typeof grantId !== "string" || !trackedPaths.has(filePath)) throw captureError("CAPTURE_TEMP_GRANT_INVALID", "截图临时文件授权无效。"); + const expiry = Number.isFinite(expiresAt) ? expiresAt : now() + 12 * 60 * 60 * 1000; + grants.set(grantId, { filePath, expiresAt: expiry, jobId: null, active: false, expired: false, timer: null }); + if (expiry <= now()) cleanupGrantSync(grantId); else scheduleExpiry(grantId); + } + + function assertConsumable(grantId) { + const grant = grants.get(grantId); + if (grant?.jobId) throw captureError("SCREEN_CAPTURE_GRANT_CONSUMED", "该截图已开始转换,请重新截图后再创建新任务。"); + } + + function markConsumed(grantId, jobId) { + const grant = grants.get(grantId); + if (grant) { grant.jobId = jobId; grant.active = true; } + } + + function markJobActive(jobId) { + for (const grant of grants.values()) if (grant.jobId === jobId) grant.active = true; + } + + function settleJob(grantId, job) { + const grant = grants.get(grantId); + if (!grant || (grant.jobId && grant.jobId !== job?.id)) return false; + grant.active = false; + const retryable = Array.isArray(job?.items) && job.items.some(item => item.status === "failed" || item.status === "cancelled"); + if (!retryable || grant.expired || grant.expiresAt <= now()) return cleanupGrantSync(grantId); + return false; + } + + function discardFile(filePath) { + for (const [grantId, grant] of grants) if (grant.filePath === filePath) clearGrantRecord(grantId); + return cleanupTrackedPathSync(filePath); + } + + function pruneExpired(at = now()) { + for (const [grantId, grant] of grants) { + if (grant.expiresAt > at) continue; + if (grant.active) grant.expired = true; + else cleanupGrantSync(grantId); + } + } + + const store = Object.freeze({ + maxBytes, + createFromDataUrl, + associateGrant, + assertConsumable, + markConsumed, + markJobActive, + settleJob, + discardFile, + cleanupGrantSync, + cleanupInactiveSync, + cleanupAllSync, + pruneExpired + }); + return store; +} + +module.exports = { DEFAULT_MAX_CAPTURE_BYTES, captureError, parseCaptureDataUrl, createCaptureTempStore }; diff --git a/plugins/format-converter/preload/host-compatibility.cjs b/plugins/format-converter/preload/host-compatibility.cjs new file mode 100644 index 000000000..de042e9db --- /dev/null +++ b/plugins/format-converter/preload/host-compatibility.cjs @@ -0,0 +1,89 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const MINIMUM_VERSION = "2.4.0"; + +function parseVersion(value) { + if (typeof value !== "string") return null; + const match = value.match(/^\s*v?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][0-9A-Za-z.-]+)?\s*$/); + if (!match) return null; + const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)]; + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function compareVersions(left, right) { + const a = parseVersion(left); + const b = parseVersion(right); + if (!a || !b) return null; + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + const leftPrerelease = typeof left === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(left); + const rightPrerelease = typeof right === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(right); + if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1; + return 0; +} + +function getHostCompatibility(ztools) { + // A missing ztools object is the explicit browser-development preview case. + if (!ztools) return { version: "", supported: true }; + let getAppVersion; + try { getAppVersion = ztools.getAppVersion; } catch { return { version: "", supported: false }; } + if (typeof getAppVersion !== "function") return { version: "", supported: false }; + let version; + try { version = getAppVersion.call(ztools); } catch { return { version: "", supported: false }; } + const comparison = compareVersions(version, MINIMUM_VERSION); + return { + version: typeof version === "string" ? version : "", + supported: comparison !== null && comparison >= 0 + }; +} + +function copyDirectory(source, destination) { + if (!fs.existsSync(source)) return false; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.cpSync(source, destination, { recursive: true, force: false, errorOnExist: false }); + return directoriesMatch(source, destination); +} + +function directoriesMatch(source, destination) { + const sourceStat = fs.statSync(source); + const destinationStat = fs.statSync(destination); + if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false; + if (!sourceStat.isDirectory()) return sourceStat.size === destinationStat.size; + const sourceNames = fs.readdirSync(source).sort(); + const destinationNames = fs.readdirSync(destination).sort(); + if (sourceNames.length !== destinationNames.length || sourceNames.some((name, index) => name !== destinationNames[index])) return false; + return sourceNames.every(name => directoriesMatch(path.join(source, name), path.join(destination, name))); +} + +function runtimeRoot(ztools, legacyRoot) { + let pluginData = ""; + try { pluginData = typeof ztools?.getPath === "function" ? ztools.getPath("pluginData") : ""; } catch {} + if (!pluginData) return { root: legacyRoot, migrated: false, modern: false }; + const root = path.join(pluginData, "runtime", "v1"); + const marker = path.join(pluginData, ".format-converter-runtime-migration-v1.json"); + let migrated = false; + try { + if (fs.existsSync(marker)) { + if (fs.existsSync(root) || !fs.existsSync(legacyRoot)) return { root, migrated, modern: true }; + return { root: legacyRoot, migrated: false, modern: false }; + } + if (fs.existsSync(legacyRoot)) { + const verified = fs.existsSync(root) + ? directoriesMatch(legacyRoot, root) + : copyDirectory(legacyRoot, root); + if (!verified) return { root: legacyRoot, migrated: false, modern: false }; + fs.writeFileSync(marker, JSON.stringify({ version: 1, source: legacyRoot, destination: root, copiedAt: new Date().toISOString() })); + migrated = true; + } + } catch { + // A failed migration must leave the old runtime usable; never remove it. + return { root: legacyRoot, migrated: false, modern: false }; + } + return { root, migrated, modern: true }; +} + +module.exports = { MINIMUM_VERSION, parseVersion, compareVersions, getHostCompatibility, copyDirectory, directoriesMatch, runtimeRoot }; diff --git a/plugins/format-converter/preload/job-manager.cjs b/plugins/format-converter/preload/job-manager.cjs index 49fc6add6..cebe3d673 100644 --- a/plugins/format-converter/preload/job-manager.cjs +++ b/plugins/format-converter/preload/job-manager.cjs @@ -83,6 +83,7 @@ function createJobManager(options) { } job.completedAt = now(); job.running = false; + try { await options.onJobSettled?.(snapshot(job)); } catch {} } function start(request, plan) { diff --git a/plugins/format-converter/preload/package.json b/plugins/format-converter/preload/package.json index 0484405d5..bc073627e 100644 --- a/plugins/format-converter/preload/package.json +++ b/plugins/format-converter/preload/package.json @@ -1,6 +1,6 @@ { "name": "format-converter-preload-runtime", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "commonjs", "license": "MIT", diff --git a/plugins/format-converter/preload/screen-capture.cjs b/plugins/format-converter/preload/screen-capture.cjs new file mode 100644 index 000000000..79ae657a9 --- /dev/null +++ b/plugins/format-converter/preload/screen-capture.cjs @@ -0,0 +1,19 @@ +"use strict"; + +function requestScreenCapture(ztools) { + if (typeof ztools?.screenCapture !== "function") { + return Promise.reject(Object.assign(new Error("请升级到 ZTools 3.2.0 以使用截图导入。"), { code: "SCREEN_CAPTURE_UNAVAILABLE" })); + } + return new Promise((resolve, reject) => { + try { + // ZTools 3.2 uses the second argument to enter the editable capture flow. + // Older hosts ignore the extra JavaScript argument. + const request = ztools.screenCapture((image, bounds) => resolve({ image, bounds }), false); + if (request && typeof request.then === "function") Promise.resolve(request).catch(reject); + } catch (error) { + reject(error); + } + }); +} + +module.exports = { requestScreenCapture }; diff --git a/plugins/format-converter/preload/services.cjs b/plugins/format-converter/preload/services.cjs index 32cf40cc6..d7bf0245c 100644 --- a/plugins/format-converter/preload/services.cjs +++ b/plugins/format-converter/preload/services.cjs @@ -2,6 +2,7 @@ const path = require("node:path"); const os = require("node:os"); +const fs = require("node:fs/promises"); const { FORMAT_DEFINITIONS, TARGET_IDS, buildAllRoutes } = require("./format-registry.cjs"); const { probeRuntimes } = require("./runtime-probe.cjs"); const { createPathPolicy, isWithin } = require("./path-policy.cjs"); @@ -9,6 +10,9 @@ const { createConversionEngine } = require("./conversion-engine.cjs"); const { createJobManager } = require("./job-manager.cjs"); const { createOfficeCliInstaller } = require("./officecli-installer.cjs"); const { createRuntimeInstaller } = require("./runtime-installer.cjs"); +const { getHostCompatibility, runtimeRoot } = require("./host-compatibility.cjs"); +const { requestScreenCapture } = require("./screen-capture.cjs"); +const { DEFAULT_MAX_CAPTURE_BYTES, createCaptureTempStore } = require("./capture-temp-store.cjs"); const TOOL_NAMES = Object.freeze({ plan: "format_conversion_plan", execute: "format_conversion_execute", job: "format_conversion_job" }); const registeredHosts = new WeakMap(); @@ -27,6 +31,42 @@ function dialogPaths(result) { return []; } +async function captureToTemporaryFile(ztools, captureStore) { + const result = await requestScreenCapture(ztools); + if (!result?.image || typeof result.image !== "string") throw Object.assign(new Error("未获取到截图。"), { code: "SCREEN_CAPTURE_CANCELLED" }); + const output = await captureStore.createFromDataUrl(result.image); + return { ...output, bounds: result.bounds }; +} + +async function canonicalApprovedOutput(filePath, approvedRoots) { + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + throw Object.assign(new Error("拖出的输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); + } + if (!approvedRoots.some(root => isWithin(root, filePath))) { + throw Object.assign(new Error("只能拖出已授权输出目录中的文件。"), { code: "PATH_NOT_APPROVED" }); + } + let stat; + try { + stat = await fs.lstat(filePath); + } catch { + throw Object.assign(new Error("拖出的输出文件不存在。"), { code: "INVALID_OUTPUT_PATH" }); + } + if (stat.isSymbolicLink()) { + throw Object.assign(new Error("不能拖出符号链接。"), { code: "OUTPUT_SYMLINK_NOT_ALLOWED" }); + } + if (!stat.isFile()) { + throw Object.assign(new Error("只能拖出常规文件。"), { code: "INVALID_OUTPUT_PATH" }); + } + const canonicalPath = await fs.realpath(filePath); + const canonicalRoots = (await Promise.all(approvedRoots.map(async root => { + try { return await fs.realpath(root); } catch { return null; } + }))).filter(Boolean); + if (!canonicalRoots.some(root => isWithin(root, canonicalPath))) { + throw Object.assign(new Error("文件真实路径不在已授权输出目录内。"), { code: "PATH_NOT_APPROVED" }); + } + return canonicalPath; +} + function validateObject(input, allowed, label) { if (!input || typeof input !== "object" || Array.isArray(input)) throw Object.assign(new Error(`${label}参数必须是对象。`), { code: "INVALID_TOOL_INPUT" }); if (Object.keys(input).some(key => !allowed.has(key))) throw Object.assign(new Error(`${label}包含未允许字段。`), { code: "INVALID_TOOL_INPUT" }); @@ -66,15 +106,27 @@ function createFormatConverterServices(target, dependencies = {}) { const ztools = target?.ztools || {}; const storage = dependencies.storage || ztools.dbStorage; const pathPolicy = dependencies.pathPolicy || createPathPolicy({ storage }); + const captureLimit = Number.isSafeInteger(pathPolicy.limits?.maxFileBytes) + ? Math.min(pathPolicy.limits.maxFileBytes, DEFAULT_MAX_CAPTURE_BYTES) + : DEFAULT_MAX_CAPTURE_BYTES; + const captureStore = dependencies.captureStore || createCaptureTempStore({ maxBytes: captureLimit }); const installer = dependencies.installer || createOfficeCliInstaller(); - const runtimeRoot = dependencies.runtimeRoot || (() => { + const legacyRuntimeRoot = (() => { try { if (typeof ztools.getPath === "function") return path.join(ztools.getPath("userData"), "format-converter", "runtime", "v1"); } catch {} return path.join(os.homedir(), ".ztools", "format-converter", "runtime", "v1"); })(); - const runtimeInstaller = dependencies.runtimeInstaller || createRuntimeInstaller({ runtimeRoot }); + const runtimeLocation = dependencies.runtimeRoot ? { root: dependencies.runtimeRoot, modern: false } : runtimeRoot(ztools, legacyRuntimeRoot); + const runtimeInstaller = dependencies.runtimeInstaller || createRuntimeInstaller({ runtimeRoot: runtimeLocation.root }); let runtimes = dependencies.runtimes || []; const engine = dependencies.engine || createConversionEngine({ pathPolicy, runtimes }); - const jobs = dependencies.jobs || createJobManager({ conversionEngine: engine, pathPolicy, concurrency: 2 }); + const jobs = dependencies.jobs || createJobManager({ + conversionEngine: engine, + pathPolicy, + concurrency: 2, + onJobSettled(job) { captureStore.settleJob(job.request?.inputGrantId, job); } + }); + + if (typeof ztools.onPluginOut === "function") ztools.onPluginOut(() => captureStore.cleanupInactiveSync()); async function refreshRuntimes() { runtimes = await (dependencies.probeRuntimes || probeRuntimes)(); @@ -104,6 +156,20 @@ function createFormatConverterServices(target, dependencies = {}) { return paths.length ? pathPolicy.createInputGrant(paths, "ui") : null; }, "INPUT_SELECTION_FAILED"); }, + captureScreen() { + return safe(async () => { + const capture = await captureToTemporaryFile(ztools, captureStore); + try { + const grant = await pathPolicy.createInputGrant([capture.path], "ui"); + captureStore.associateGrant(grant.id, capture.path, grant.expiresAt); + return grant; + } catch (error) { + captureStore.discardFile(capture.path); + throw error; + } + }, "SCREEN_CAPTURE_FAILED"); + }, + canCaptureScreen() { return typeof ztools.screenCapture === "function"; }, acceptInputs(paths) { return safe(() => pathPolicy.createInputGrant(paths, "ui"), "INPUT_VALIDATION_FAILED"); }, selectOutputDirectory() { return safe(async () => { @@ -116,12 +182,25 @@ function createFormatConverterServices(target, dependencies = {}) { getApprovedRoots() { return safe(() => pathPolicy.approvedRoots()); }, removeApprovedRoot(root) { return safe(() => pathPolicy.removeApprovedRoot(root)); }, planConversion(request) { return safe(async () => { await ensureRuntimes(); return engine.plan(request); }, "CONVERSION_PLAN_FAILED"); }, - startConversion(request) { return safe(async () => { await ensureRuntimes(); const plan = engine.plan(request); if (!plan.executable) throw Object.assign(new Error("转换路线缺少所需引擎。"), { code: "ENGINE_UNAVAILABLE", details: plan.warnings }); return jobs.start(request, plan); }, "CONVERSION_START_FAILED"); }, + startConversion(request) { return safe(async () => { await ensureRuntimes(); captureStore.assertConsumable(request?.inputGrantId); const plan = engine.plan(request); if (!plan.executable) throw Object.assign(new Error("转换路线缺少所需引擎。"), { code: "ENGINE_UNAVAILABLE", details: plan.warnings }); const job = jobs.start(request, plan); captureStore.markConsumed(request.inputGrantId, job.id); return job; }, "CONVERSION_START_FAILED"); }, getJob(jobId) { return safe(() => jobs.get(jobId), "JOB_QUERY_FAILED"); }, cancelJob(jobId) { return safe(() => jobs.cancel(jobId), "JOB_CANCEL_FAILED"); }, - retryFailed(jobId) { return safe(() => jobs.retryFailed(jobId), "JOB_RETRY_FAILED"); }, + retryFailed(jobId) { return safe(() => { const job = jobs.retryFailed(jobId); captureStore.markJobActive(job.id); return job; }, "JOB_RETRY_FAILED"); }, installRuntime(runtimeId) { return safe(async () => { await runtimeInstaller.install(runtimeId); return refreshRuntimes(); }, "RUNTIME_INSTALL_FAILED"); }, installOfficeCli() { return safe(async () => { await installer.install(); const next = await refreshRuntimes(); return next.find(item => item.id === "officecli"); }, "OFFICECLI_INSTALL_FAILED"); }, + hostCompatibility() { return getHostCompatibility(target?.ztools); }, + canStartDrag() { return typeof ztools.startDrag === "function"; }, + startDrag(paths) { + return safe(async () => { + const values = Array.isArray(paths) ? paths : [paths]; + if (!values.length || values.some(item => typeof item !== "string" || !path.isAbsolute(item))) throw Object.assign(new Error("拖出的输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); + const roots = await pathPolicy.approvedRoots(); + const canonicalPaths = await Promise.all(values.map(item => canonicalApprovedOutput(item, roots))); + if (typeof ztools.startDrag !== "function") throw Object.assign(new Error("请升级到 ZTools 3.2.0 以拖出文件。"), { code: "START_DRAG_UNAVAILABLE" }); + await Promise.resolve(ztools.startDrag(canonicalPaths.length === 1 ? canonicalPaths[0] : canonicalPaths)); + return true; + }, "START_DRAG_FAILED"); + }, revealPath(filePath) { return safe(async () => { if (typeof filePath !== "string" || !path.isAbsolute(filePath)) throw Object.assign(new Error("输出路径无效。"), { code: "INVALID_OUTPUT_PATH" }); @@ -155,7 +234,7 @@ function createFormatConverterServices(target, dependencies = {}) { } async function jobForMcp(input) { const value = validateJobToolInput(input); return value.action === "cancel" ? jobs.cancel(value.jobId) : jobs.get(value.jobId); } - return { services, tools: { planForMcp, executeForMcp, jobForMcp }, internals: { pathPolicy, engine, jobs, runtimeInstaller } }; + return { services, tools: { planForMcp, executeForMcp, jobForMcp }, internals: { pathPolicy, engine, jobs, runtimeInstaller, captureStore } }; } function throwToolFailure(result) { if (result?.ok) return result.data; const error = new Error(result?.error?.message || "格式转换失败。"); error.code = result?.error?.code || "FORMAT_CONVERTER_ERROR"; error.details = result?.error?.details; throw error; } @@ -173,6 +252,12 @@ function registerTools(target, bundle) { function attachFormatConverter(target, dependencies) { if (!target || (typeof target !== "object" && typeof target !== "function")) throw new Error("需要 window-like 目标挂载格式转换服务。"); + const compatibility = getHostCompatibility(target.ztools); + if (!compatibility.supported) { + const services = Object.freeze({ hostCompatibility: () => compatibility }); + target.formatConverter = services; + return { services, tools: Object.freeze({}), internals: Object.freeze({}) }; + } const bundle = createFormatConverterServices(target, dependencies); target.formatConverter = bundle.services; registerTools(target, bundle); @@ -182,4 +267,4 @@ function attachFormatConverter(target, dependencies) { let defaultBundle = null; if (typeof window !== "undefined") defaultBundle = attachFormatConverter(window); -module.exports = { TOOL_NAMES, envelope, failure, safe, dialogPaths, validatePlanToolInput, validateExecuteToolInput, validateJobToolInput, createFormatConverterServices, registerTools, attachFormatConverter, defaultBundle }; +module.exports = { TOOL_NAMES, envelope, failure, safe, dialogPaths, captureToTemporaryFile, canonicalApprovedOutput, validatePlanToolInput, validateExecuteToolInput, validateJobToolInput, createFormatConverterServices, registerTools, attachFormatConverter, defaultBundle }; diff --git a/plugins/format-converter/scripts/verify-dist.mjs b/plugins/format-converter/scripts/verify-dist.mjs index 21e3161d0..a92aa8b73 100644 --- a/plugins/format-converter/scripts/verify-dist.mjs +++ b/plugins/format-converter/scripts/verify-dist.mjs @@ -16,7 +16,7 @@ async function requireFile(relativePath) { const required = [ "index.html", "plugin.json", "logo.svg", "README.md", "CHANGELOG.md", "LICENSE", "THIRD_PARTY_NOTICES.md", - "preload/services.cjs", "preload/format-registry.cjs", "preload/path-policy.cjs", "preload/runtime-probe.cjs", + "preload/services.cjs", "preload/capture-temp-store.cjs", "preload/format-registry.cjs", "preload/path-policy.cjs", "preload/runtime-probe.cjs", "preload/conversion-engine.cjs", "preload/job-manager.cjs", "preload/engine-utils.cjs", "preload/text-converter.cjs", "preload/image-converter.cjs", "preload/pdf-converter.cjs", "preload/office-converter.cjs", "preload/officecli-installer.cjs", "preload/runtime-loader.cjs", "preload/runtime-installer.cjs", "preload/runtime-manifest.json", diff --git a/plugins/format-converter/src/App.tsx b/plugins/format-converter/src/App.tsx index 41ebb582c..5d2ad0315 100644 --- a/plugins/format-converter/src/App.tsx +++ b/plugins/format-converter/src/App.tsx @@ -18,6 +18,7 @@ import { LoaderCircle, Play, RefreshCw, + ScanLine, RotateCcw, Route, Settings2, @@ -72,7 +73,23 @@ function statusIcon(status: string) { return ; } +function rendererHostIsSupported() { + if (!window.ztools) return true; + try { + return window.formatConverter?.hostCompatibility?.().supported === true; + } catch { + return false; + } +} + export default function App() { + if (!rendererHostIsSupported()) { + return
当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用格式转换。
; + } + return ; +} + +function FormatConverterApp() { const api = window.formatConverter; const [capabilities, setCapabilities] = useState(null); const [inputGrant, setInputGrant] = useState(null); @@ -166,6 +183,21 @@ export default function App() { } finally { setBusy(null); } }; + const captureScreen = async () => { + if (!api) return; + setBusy("inputs"); setError(""); + try { setInputGrant(unwrap(await api.captureScreen())); setJob(null); } + catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + finally { setBusy(null); } + }; + + const dragOutput = async (event: React.DragEvent, outputs: string[]) => { + event.preventDefault(); + if (!api) return; + try { unwrap(await api.startDrag(outputs)); } + catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + }; + const selectOutput = async () => { if (!api) return; setBusy("output"); setError(""); @@ -289,6 +321,7 @@ export default function App() { {inputGrant ? "继续添加或重新选择" : "拖入文件,或点击选择"} Office、PDF、图片、文本与数据文件 · 最多 200 个 + {api?.canCaptureScreen() && }
{inputGrant?.files.map(file => { const definition = formatDefinition(file.format); @@ -364,7 +397,7 @@ export default function App() {
{progressLabel}{["queued", "running"].includes(job.status) && }{["partial", "failed"].includes(job.status) && }
-
{job.items.map(item =>
{statusIcon(item.status)}
{item.input.name}{item.route.description}
{item.progress}%{item.outputs.length > 0 && }{item.error && {item.error.message}}
)}
+
{job.items.map(item =>
{statusIcon(item.status)}
{item.input.name}{item.route.description}
{item.progress}%{item.outputs.length > 0 && }{item.error && {item.error.message}}
)}
}
diff --git a/plugins/format-converter/src/types.ts b/plugins/format-converter/src/types.ts index 316eaf753..ed667c234 100644 --- a/plugins/format-converter/src/types.ts +++ b/plugins/format-converter/src/types.ts @@ -141,6 +141,8 @@ export interface FormatConverterApi { getCapabilities(): Promise>; refreshRuntimes(): Promise>; selectInputs(): Promise>; + captureScreen(): Promise>; + canCaptureScreen(): boolean; acceptInputs(paths: string[]): Promise>; selectOutputDirectory(): Promise>; getApprovedRoots(): Promise>; @@ -153,6 +155,9 @@ export interface FormatConverterApi { installRuntime(runtimeId: string): Promise>; installOfficeCli(): Promise>; revealPath(path: string): Promise>; + hostCompatibility(): { version: string; supported: boolean }; + canStartDrag(): boolean; + startDrag(paths: string[]): Promise>; } export interface ZToolsLaunchParam { @@ -166,6 +171,8 @@ export interface ZToolsApi { onPluginOut?(callback: () => void): void; getPathForFile?(file: File): string; getPath?(name: string): string; + getAppVersion?(): string; + screenCapture?(callback: (image: string, bounds?: unknown) => void, autoConfirm?: boolean): unknown; } declare global { diff --git a/plugins/format-converter/tests/preload/capture-temp-store.test.cjs b/plugins/format-converter/tests/preload/capture-temp-store.test.cjs new file mode 100644 index 000000000..38f84308e --- /dev/null +++ b/plugins/format-converter/tests/preload/capture-temp-store.test.cjs @@ -0,0 +1,186 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { createCaptureTempStore } = require("../../preload/capture-temp-store.cjs"); + +function imageData(bytes = Buffer.from("capture")) { + return `data:image/png;base64,${bytes.toString("base64")}`; +} + +test("creates an unpredictable private root and a private regular file", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-secure-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const capture = await store.createFromDataUrl(imageData()); + const root = path.dirname(capture.path); + assert.match(path.basename(root), new RegExp(`^ztools-format-converter-${process.pid}-.{6}$`)); + assert.equal(fs.realpathSync(root), root); + assert.equal(fs.realpathSync(capture.path), capture.path); + assert.equal(fs.lstatSync(capture.path).isFile(), true); + assert.equal(fs.statSync(root).mode & 0o777, 0o700); + assert.equal(fs.statSync(capture.path).mode & 0o777, 0o600); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("rejects oversized data before creating a temporary root", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-limit-")); + const store = createCaptureTempStore({ tempDirectory: parent, maxBytes: 4 }); + try { + await assert.rejects(store.createFromDataUrl(imageData(Buffer.alloc(5, 1))), error => error.code === "SCREEN_CAPTURE_TOO_LARGE"); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("does not follow a preoccupied symlink file", async t => { + if (process.platform === "win32") { t.skip("symlink creation requires elevated privileges on Windows"); return; } + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-conflict-")); + const outside = path.join(parent, "outside.txt"); + const store = createCaptureTempStore({ tempDirectory: parent, randomId: () => "fixed" }); + try { + fs.writeFileSync(outside, "outside"); + const first = await store.createFromDataUrl(imageData()); + fs.unlinkSync(first.path); + fs.symlinkSync(outside, first.path); + await assert.rejects(store.createFromDataUrl(imageData(Buffer.from("replacement"))), error => error.code === "CAPTURE_TEMP_CONFLICT"); + assert.equal(fs.readFileSync(outside, "utf8"), "outside"); + } finally { + store.cleanupAllSync(); + assert.equal(fs.readFileSync(outside, "utf8"), "outside"); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("rejects a replaced root and cleanup never follows the replacement symlink", async t => { + if (process.platform === "win32") { t.skip("symlink creation requires elevated privileges on Windows"); return; } + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-root-link-")); + const outside = path.join(parent, "outside"); + const store = createCaptureTempStore({ tempDirectory: parent }); + let moved; + try { + const first = await store.createFromDataUrl(imageData()); + const root = path.dirname(first.path); + moved = `${root}-moved`; + fs.mkdirSync(outside); + fs.renameSync(root, moved); + fs.writeFileSync(path.join(outside, path.basename(first.path)), "outside"); + fs.symlinkSync(outside, root, "dir"); + await assert.rejects(store.createFromDataUrl(imageData()), error => error.code === "CAPTURE_TEMP_BOUNDARY"); + store.cleanupAllSync(); + assert.equal(fs.existsSync(root), false); + assert.equal(fs.readFileSync(path.join(outside, path.basename(first.path)), "utf8"), "outside"); + } finally { + store.cleanupAllSync(); + if (moved) fs.rmSync(moved, { recursive: true, force: true }); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("retains retryable captures, then removes consumed and expired grants", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-grants-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const consumed = await store.createFromDataUrl(imageData()); + store.associateGrant("capture-1", consumed.path, Date.now() + 60_000); + store.markConsumed("capture-1", "job-1"); + assert.throws(() => store.assertConsumable("capture-1"), error => error.code === "SCREEN_CAPTURE_GRANT_CONSUMED"); + assert.equal(store.settleJob("capture-1", { id: "job-1", items: [{ status: "failed" }] }), false); + assert.equal(fs.existsSync(consumed.path), true); + assert.equal(store.settleJob("capture-1", { id: "job-1", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(consumed.path), false); + + const expired = await store.createFromDataUrl(imageData(Buffer.from("expired"))); + store.associateGrant("capture-2", expired.path, Date.now() + 60_000); + store.pruneExpired(Date.now() + 60_001); + assert.equal(fs.existsSync(expired.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("defers expiry while a conversion is active, then cleans after its settled result", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-active-expiry-")); + let clock = 1_000; + const store = createCaptureTempStore({ tempDirectory: parent, now: () => clock }); + try { + const capture = await store.createFromDataUrl(imageData()); + store.associateGrant("capture-active", capture.path, 1_100); + store.markConsumed("capture-active", "job-active"); + clock = 1_101; + store.pruneExpired(); + assert.equal(fs.existsSync(capture.path), true); + assert.equal(store.settleJob("capture-active", { id: "job-active", items: [{ status: "failed" }] }), true); + assert.equal(fs.existsSync(capture.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("plugin lifecycle cleanup removes inactive captures but preserves active work until settle", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-lifecycle-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const inactive = await store.createFromDataUrl(imageData(Buffer.from("inactive"))); + store.associateGrant("capture-inactive", inactive.path, Date.now() + 60_000); + const active = await store.createFromDataUrl(imageData(Buffer.from("active"))); + store.associateGrant("capture-active", active.path, Date.now() + 60_000); + store.markConsumed("capture-active", "job-active"); + + store.cleanupInactiveSync(); + assert.equal(fs.existsSync(inactive.path), false); + assert.equal(fs.existsSync(active.path), true); + + assert.equal(store.settleJob("capture-active", { id: "job-active", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(active.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("plugin lifecycle cleanup preserves a failed capture while its retry grant is valid", async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "format-capture-retry-lifecycle-")); + const store = createCaptureTempStore({ tempDirectory: parent }); + try { + const capture = await store.createFromDataUrl(imageData(Buffer.from("retryable"))); + store.associateGrant("capture-retry", capture.path, Date.now() + 60_000); + store.markConsumed("capture-retry", "job-retry"); + assert.equal(store.settleJob("capture-retry", { id: "job-retry", items: [{ status: "failed" }] }), false); + + store.cleanupInactiveSync(); + assert.equal(fs.existsSync(capture.path), true); + + store.markJobActive("job-retry"); + assert.equal(store.settleJob("capture-retry", { id: "job-retry", items: [{ status: "succeeded" }] }), true); + assert.equal(fs.existsSync(capture.path), false); + assert.deepEqual(fs.readdirSync(parent), []); + } finally { + store.cleanupAllSync(); + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test("process exit hook removes tracked capture files and their root", () => { + const modulePath = path.resolve(__dirname, "../../preload/capture-temp-store.cjs"); + const script = `const { createCaptureTempStore } = require(${JSON.stringify(modulePath)}); (async () => { const store = createCaptureTempStore(); const item = await store.createFromDataUrl('data:image/png;base64,Y2FwdHVyZQ=='); store.associateGrant('active', item.path, Date.now() + 60_000); store.markConsumed('active', 'job'); console.log(require('node:path').dirname(item.path)); })().catch(error => { console.error(error); process.exitCode = 1; });`; + const result = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const root = result.stdout.trim(); + assert.ok(root); + assert.equal(fs.existsSync(root), false); +}); diff --git a/plugins/format-converter/tests/preload/host-compatibility.test.cjs b/plugins/format-converter/tests/preload/host-compatibility.test.cjs new file mode 100644 index 000000000..aecffc3d3 --- /dev/null +++ b/plugins/format-converter/tests/preload/host-compatibility.test.cjs @@ -0,0 +1,57 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { compareVersions, copyDirectory, directoriesMatch, getHostCompatibility, runtimeRoot } = require("../../preload/host-compatibility.cjs"); + +test("host compatibility only bypasses an explicit browser preview", () => { + assert.equal(compareVersions("3.2.0", "3.1.9"), 1); + assert.equal(compareVersions("2.4", "2.4.0"), 0); + assert.equal(compareVersions("2.4.0-beta.1", "2.4.0"), -1); + assert.equal(compareVersions("unknown", "2.4.0"), null); + assert.equal(getHostCompatibility(undefined).supported, true); + assert.equal(getHostCompatibility({}).supported, false); + const throwingGetter = {}; + Object.defineProperty(throwingGetter, "getAppVersion", { get() { throw new Error("unavailable"); } }); + assert.equal(getHostCompatibility(throwingGetter).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => { throw new Error("unavailable"); } }).supported, false); + for (const version of ["", "unknown", 320]) { + assert.equal(getHostCompatibility({ getAppVersion: () => version }).supported, false); + } + assert.equal(getHostCompatibility({ getAppVersion: () => "2.3.9" }).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => "2.4.0-beta.1" }).supported, false); + assert.equal(getHostCompatibility({ getAppVersion: () => "2.4.0" }).supported, true); + assert.equal(getHostCompatibility({ getAppVersion: () => "3.1.9" }).supported, true); +}); + +test("runtime migration copy verifies its complete directory before writing a marker", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-")); + try { + const source = path.join(root, "old"); + const destination = path.join(root, "new"); + fs.mkdirSync(path.join(source, "nested"), { recursive: true }); + fs.writeFileSync(path.join(source, "nested", "runtime.bin"), "runtime"); + assert.equal(copyDirectory(source, destination), true); + assert.equal(directoriesMatch(source, destination), true); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test("runtime migration keeps using legacy data when an unverified destination already exists", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-partial-")); + try { + const pluginData = path.join(base, "plugin-data"); + const legacy = path.join(base, "legacy"); + const destination = path.join(pluginData, "runtime", "v1"); + fs.mkdirSync(legacy, { recursive: true }); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(legacy, "runtime.bin"), "complete"); + fs.writeFileSync(path.join(destination, "runtime.bin"), "partial"); + + const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); + assert.equal(resolved.root, legacy); + assert.equal(resolved.modern, false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v1.json")), false); + } finally { fs.rmSync(base, { recursive: true, force: true }); } +}); diff --git a/plugins/format-converter/tests/preload/job-manager.test.cjs b/plugins/format-converter/tests/preload/job-manager.test.cjs index 90035b85a..4522fe291 100644 --- a/plugins/format-converter/tests/preload/job-manager.test.cjs +++ b/plugins/format-converter/tests/preload/job-manager.test.cjs @@ -68,3 +68,19 @@ test("expires completed jobs and enforces a bounded job store", async () => { assert.throws(() => activeManager.start(request, plan), error => error.code === "JOB_CAPACITY_REACHED"); for (const job of activeManager._jobs.values()) activeManager.cancel(job.id); }); + +test("notifies lifecycle cleanup after the job has fully settled", async () => { + let settled; + const manager = createJobManager({ + pathPolicy: { requireOutputGrant: () => ({ directory: "/authorized" }) }, + conversionEngine: { async convertItem() { return { outputs: ["/authorized/ok.txt"], warnings: [] }; } }, + onJobSettled(job) { settled = job; } + }); + const request = { inputGrantId: "capture-grant", outputGrantId: "output", target: "txt", profile: "extract", collision: "rename", options: {} }; + const plan = { items: [{ input: { name: "a.txt", path: "/a.txt", format: "txt", family: "text", size: 1 }, route: { description: "test" } }] }; + const started = manager.start(request, plan); + await waitFor(() => settled?.id === started.id); + assert.equal(settled.status, "succeeded"); + assert.equal(settled.running, false); + assert.equal(settled.request.inputGrantId, "capture-grant"); +}); diff --git a/plugins/format-converter/tests/preload/screen-capture.test.cjs b/plugins/format-converter/tests/preload/screen-capture.test.cjs new file mode 100644 index 000000000..ab0c0a79a --- /dev/null +++ b/plugins/format-converter/tests/preload/screen-capture.test.cjs @@ -0,0 +1,28 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { requestScreenCapture } = require("../../preload/screen-capture.cjs"); + +test("screen capture keeps the ZTools 3.2 image and bounds callback", async () => { + let receivedAutoConfirm; + const result = await requestScreenCapture({ + screenCapture(callback, autoConfirm) { + receivedAutoConfirm = autoConfirm; + callback("data:image/png;base64,aGVsbG8=", { x: 1, y: 2, width: 3, height: 4 }); + return Promise.resolve(); + }, + }); + assert.equal(receivedAutoConfirm, false); + assert.deepEqual(result, { + image: "data:image/png;base64,aGVsbG8=", + bounds: { x: 1, y: 2, width: 3, height: 4 }, + }); +}); + +test("screen capture propagates asynchronous host rejection", async () => { + await assert.rejects( + requestScreenCapture({ screenCapture() { return Promise.reject(new Error("capture ipc failed")); } }), + /capture ipc failed/, + ); +}); diff --git a/plugins/format-converter/tests/preload/services.test.cjs b/plugins/format-converter/tests/preload/services.test.cjs index dfc5e4eda..5ea0d5201 100644 --- a/plugins/format-converter/tests/preload/services.test.cjs +++ b/plugins/format-converter/tests/preload/services.test.cjs @@ -2,6 +2,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { TOOL_NAMES, @@ -35,6 +37,7 @@ function fixture(overrides = {}) { ...overrides.jobs }; const ztools = { + getAppVersion: () => "3.2.0", dbStorage: { getItem: () => [], setItem() {} }, registerTool(name, handler) { calls.tools.set(name, handler); }, ...overrides.ztools @@ -54,14 +57,66 @@ function fixture(overrides = {}) { test("exposes only the narrow renderer bridge and registers the exact MCP tools", () => { const { target, calls } = fixture(); assert.deepEqual(Object.keys(target.formatConverter).sort(), [ - "acceptInputs", "cancelJob", "getApprovedRoots", "getCapabilities", "getJob", + "acceptInputs", "canCaptureScreen", "canStartDrag", "cancelJob", "captureScreen", "getApprovedRoots", "getCapabilities", "getJob", "hostCompatibility", "installOfficeCli", "installRuntime", "planConversion", "refreshRuntimes", "removeApprovedRoot", - "retryFailed", "revealPath", "selectInputs", "selectOutputDirectory", "startConversion" + "retryFailed", "revealPath", "selectInputs", "selectOutputDirectory", "startConversion", "startDrag" ]); assert.deepEqual([...calls.tools.keys()].sort(), Object.values(TOOL_NAMES).sort()); assert.equal(Object.isFrozen(target.formatConverter), true); }); +test("fails closed before constructing services or registering tools when the host version is unavailable", () => { + let registered = 0; + const target = { ztools: { registerTool() { registered += 1; } } }; + const bundle = attachFormatConverter(target, { + get pathPolicy() { throw new Error("business initialization must not run"); } + }); + assert.deepEqual(Object.keys(target.formatConverter), ["hostCompatibility"]); + assert.equal(target.formatConverter.hostCompatibility().supported, false); + assert.equal(Object.keys(bundle.tools).length, 0); + assert.equal(registered, 0); +}); + +test("startDrag rejects symlink escapes and passes only canonical regular files", async () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-drag-boundary-")); + try { + const approved = path.join(base, "approved"); + const outside = path.join(base, "outside"); + fs.mkdirSync(approved); + fs.mkdirSync(outside); + const regular = path.join(approved, "result.pdf"); + const outsideFile = path.join(outside, "secret.pdf"); + fs.writeFileSync(regular, "result"); + fs.writeFileSync(outsideFile, "secret"); + const directorySymlink = path.join(approved, "linked-directory"); + const directSymlink = process.platform === "win32" ? null : path.join(approved, "direct-link.pdf"); + if (directSymlink) fs.symlinkSync(outsideFile, directSymlink); + fs.symlinkSync(outside, directorySymlink, process.platform === "win32" ? "junction" : "dir"); + + const dragged = []; + const { target } = fixture({ + pathPolicy: { approvedRoots: async () => [approved] }, + ztools: { startDrag(value) { dragged.push(value); } } + }); + + if (directSymlink) { + const directResult = await target.formatConverter.startDrag(directSymlink); + assert.equal(directResult.ok, false); + assert.equal(directResult.error.code, "OUTPUT_SYMLINK_NOT_ALLOWED"); + } + + const escapedResult = await target.formatConverter.startDrag(path.join(directorySymlink, "secret.pdf")); + assert.equal(escapedResult.ok, false); + assert.equal(escapedResult.error.code, "PATH_NOT_APPROVED"); + + const regularResult = await target.formatConverter.startDrag(regular); + assert.equal(regularResult.ok, true); + assert.deepEqual(dragged, [fs.realpathSync(regular)]); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + test("file and directory dialogs create grants instead of returning raw filesystem access", async () => { let dialogCall = 0; const input = path.resolve("/approved/input.txt"); @@ -84,6 +139,89 @@ test("file and directory dialogs create grants instead of returning raw filesyst assert.deepEqual(calls.outputs, [output]); }); +test("screen capture cleans its private file on grant failure and plugin out", async () => { + let pluginOut; + let rejectedPath; + const first = fixture({ + pathPolicy: { + async createInputGrant(paths) { + rejectedPath = paths[0]; + throw Object.assign(new Error("invalid image"), { code: "MAGIC_MISMATCH" }); + } + }, + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const rejected = await first.target.formatConverter.captureScreen(); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, "MAGIC_MISMATCH"); + assert.equal(fs.existsSync(rejectedPath), false); + first.bundle.internals.captureStore.cleanupAllSync(); + + const second = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await second.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = second.calls.inputs.at(-1)[0]; + const captureRoot = path.dirname(capturedPath); + assert.equal(fs.existsSync(capturedPath), true); + pluginOut(); + assert.equal(fs.existsSync(capturedPath), false); + assert.equal(fs.existsSync(captureRoot), false); +}); + +test("plugin out preserves an active screenshot until its job settles", async () => { + let pluginOut; + const current = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await current.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = current.calls.inputs.at(-1)[0]; + const captureRoot = path.dirname(capturedPath); + const started = await current.target.formatConverter.startConversion({ inputGrantId: captured.data.id, outputGrantId: "output-grant", target: "png", profile: "visual", collision: "rename", options: {} }); + assert.equal(started.ok, true); + + pluginOut(); + assert.equal(fs.existsSync(capturedPath), true); + current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "succeeded" }] }); + assert.equal(fs.existsSync(capturedPath), false); + assert.equal(fs.existsSync(captureRoot), false); +}); + +test("plugin out preserves a failed screenshot so retry can reuse it", async () => { + let pluginOut; + const current = fixture({ + ztools: { + screenCapture(callback) { callback("data:image/png;base64,Y2FwdHVyZQ=="); }, + onPluginOut(callback) { pluginOut = callback; } + } + }); + const captured = await current.target.formatConverter.captureScreen(); + assert.equal(captured.ok, true); + const capturedPath = current.calls.inputs.at(-1)[0]; + const started = await current.target.formatConverter.startConversion({ inputGrantId: captured.data.id, outputGrantId: "output-grant", target: "png", profile: "visual", collision: "rename", options: {} }); + assert.equal(started.ok, true); + assert.equal(current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "failed" }] }), false); + + pluginOut(); + assert.equal(fs.existsSync(capturedPath), true); + const retried = await current.target.formatConverter.retryFailed(started.data.id); + assert.equal(retried.ok, true); + assert.equal(fs.existsSync(capturedPath), true); + current.bundle.internals.captureStore.settleJob(captured.data.id, { id: started.data.id, items: [{ status: "succeeded" }] }); + assert.equal(fs.existsSync(capturedPath), false); +}); + test("execute tool accepts its own fields and rejects unknown nested options", () => { const valid = validateExecuteToolInput({ inputs: [path.resolve("/approved/input.txt")], From ffe9d077310cda2b14c35e997bb6f23563601fee Mon Sep 17 00:00:00 2001 From: wangzihao Date: Wed, 2 Sep 2026 14:38:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(format-converter):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B7=B2=E8=BF=81=E7=A7=BB=E7=9A=84=E6=97=A7=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 SHA-256 校验 runtime 迁移结果,确认成功后删除 userData 旧目录并记录迁移状态。 AI-Co-Authored-By: Codex (GPT-5) --- plugins/format-converter/CHANGELOG.md | 2 +- plugins/format-converter/README.md | 4 +-- .../preload/host-compatibility.cjs | 30 ++++++++++++------- .../tests/preload/host-compatibility.test.cjs | 17 ++++++++++- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/plugins/format-converter/CHANGELOG.md b/plugins/format-converter/CHANGELOG.md index bf53f5c55..f7b4b4d07 100644 --- a/plugins/format-converter/CHANGELOG.md +++ b/plugins/format-converter/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.1.1 - 2026-09-01 - 适配 ZTools 3.2:截图导入、转换结果外拖,以及 runtime 使用 `pluginData` 专属目录。 -- runtime 迁移保留旧 `userData` 副本,ZTools 2.4–3.1 继续从原路径工作。 +- runtime 迁移在完整校验后删除旧 `userData` 副本;ZTools 2.4–3.1 直接安装仍从原路径工作。 - 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务初始化前显示升级提示,仅浏览器开发预览放行。 - 输出外拖拒绝符号链接、非普通文件及真实路径逃逸授权目录的文件,并只向宿主传递规范化真实路径。 diff --git a/plugins/format-converter/README.md b/plugins/format-converter/README.md index 49d8c0139..440432ccf 100644 --- a/plugins/format-converter/README.md +++ b/plugins/format-converter/README.md @@ -5,7 +5,7 @@ ## ZTools 兼容性 - ZTools 3.2.0:支持截图导入、转换输出外拖,并将可重建 runtime 优先放入 `pluginData`。 -- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。 +- ZTools 2.4–3.1:继续使用原 `userData` runtime、文件选择和打开目录流程。3.2 首次启动会校验迁移 runtime 并删除旧副本。 - runtime 迁移为复制后保留旧目录,不会在升级时删除旧数据,支持临时降级宿主。 - 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 @@ -29,7 +29,7 @@ - OCR:Tesseract.js,以及中英文模型;模型随 OCR 引擎从国内镜像一次安装,识别时不再访问外网。 - Excel 引擎:ExcelJS 与工作簿依赖。 -安装器不调用终端或依赖本机 npm。它读取仓库内由 `package-lock.json` 生成的固定清单,优先从 npmmirror 下载 tarball,失败后回退 npm 官方源;每个包通过 SRI/SHA-512 校验后才安全解压到 ZTools `userData/format-converter/runtime/v1`。压缩包中的绝对路径、路径穿越、符号链接和设备文件都会被拒绝,写入采用临时目录与原子替换。 +安装器不调用终端或依赖本机 npm。它读取仓库内由 `package-lock.json` 生成的固定清单,优先从 npmmirror 下载 tarball,失败后回退 npm 官方源;每个包通过 SRI/SHA-512 校验后才安全解压到 ZTools 3.2 的插件专属 `pluginData/runtime/v1`,旧宿主使用 `userData/format-converter/runtime/v1`。压缩包中的绝对路径、路径穿越、符号链接和设备文件都会被拒绝,写入采用临时目录与原子替换。 ## 开发 diff --git a/plugins/format-converter/preload/host-compatibility.cjs b/plugins/format-converter/preload/host-compatibility.cjs index de042e9db..2e3f7a13b 100644 --- a/plugins/format-converter/preload/host-compatibility.cjs +++ b/plugins/format-converter/preload/host-compatibility.cjs @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const crypto = require("node:crypto"); const MINIMUM_VERSION = "2.4.0"; @@ -49,10 +50,15 @@ function copyDirectory(source, destination) { } function directoriesMatch(source, destination) { - const sourceStat = fs.statSync(source); - const destinationStat = fs.statSync(destination); + const sourceStat = fs.lstatSync(source); + const destinationStat = fs.lstatSync(destination); + if (sourceStat.isSymbolicLink() || destinationStat.isSymbolicLink()) return false; if (sourceStat.isDirectory() !== destinationStat.isDirectory()) return false; - if (!sourceStat.isDirectory()) return sourceStat.size === destinationStat.size; + if (!sourceStat.isDirectory()) { + return sourceStat.size === destinationStat.size + && crypto.createHash("sha256").update(fs.readFileSync(source)).digest("hex") + === crypto.createHash("sha256").update(fs.readFileSync(destination)).digest("hex"); + } const sourceNames = fs.readdirSync(source).sort(); const destinationNames = fs.readdirSync(destination).sort(); if (sourceNames.length !== destinationNames.length || sourceNames.some((name, index) => name !== destinationNames[index])) return false; @@ -64,23 +70,27 @@ function runtimeRoot(ztools, legacyRoot) { try { pluginData = typeof ztools?.getPath === "function" ? ztools.getPath("pluginData") : ""; } catch {} if (!pluginData) return { root: legacyRoot, migrated: false, modern: false }; const root = path.join(pluginData, "runtime", "v1"); - const marker = path.join(pluginData, ".format-converter-runtime-migration-v1.json"); + const marker = path.join(pluginData, ".format-converter-runtime-migration-v2.json"); let migrated = false; try { - if (fs.existsSync(marker)) { - if (fs.existsSync(root) || !fs.existsSync(legacyRoot)) return { root, migrated, modern: true }; - return { root: legacyRoot, migrated: false, modern: false }; - } if (fs.existsSync(legacyRoot)) { const verified = fs.existsSync(root) ? directoriesMatch(legacyRoot, root) : copyDirectory(legacyRoot, root); if (!verified) return { root: legacyRoot, migrated: false, modern: false }; - fs.writeFileSync(marker, JSON.stringify({ version: 1, source: legacyRoot, destination: root, copiedAt: new Date().toISOString() })); + fs.rmSync(legacyRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (fs.existsSync(legacyRoot)) return { root: legacyRoot, migrated: false, modern: false }; + try { fs.rmdirSync(path.dirname(legacyRoot)); } catch {} + try { fs.rmdirSync(path.dirname(path.dirname(legacyRoot))); } catch {} migrated = true; } + fs.mkdirSync(pluginData, { recursive: true }); + fs.writeFileSync(marker, JSON.stringify({ version: 2, destination: root, completedAt: new Date().toISOString() })); } catch { - // A failed migration must leave the old runtime usable; never remove it. + // Copy/verification failures retain the old runtime. If cleanup already + // completed, keep using the verified pluginData copy even if marker write failed. + if (fs.existsSync(legacyRoot)) return { root: legacyRoot, migrated: false, modern: false }; + if (fs.existsSync(root)) return { root, migrated: true, modern: true }; return { root: legacyRoot, migrated: false, modern: false }; } return { root, migrated, modern: true }; diff --git a/plugins/format-converter/tests/preload/host-compatibility.test.cjs b/plugins/format-converter/tests/preload/host-compatibility.test.cjs index aecffc3d3..176daf7fd 100644 --- a/plugins/format-converter/tests/preload/host-compatibility.test.cjs +++ b/plugins/format-converter/tests/preload/host-compatibility.test.cjs @@ -38,6 +38,21 @@ test("runtime migration copy verifies its complete directory before writing a ma } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test("runtime migration removes the verified userData copy", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-move-")); + try { + const pluginData = path.join(base, "plugin-data"); + const legacy = path.join(base, "legacy"); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, "runtime.bin"), "runtime"); + const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); + assert.equal(resolved.root, path.join(pluginData, "runtime", "v1")); + assert.equal(resolved.migrated, true); + assert.equal(fs.existsSync(legacy), false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v2.json")), true); + } finally { fs.rmSync(base, { recursive: true, force: true }); } +}); + test("runtime migration keeps using legacy data when an unverified destination already exists", () => { const base = fs.mkdtempSync(path.join(os.tmpdir(), "format-host-compat-partial-")); try { @@ -52,6 +67,6 @@ test("runtime migration keeps using legacy data when an unverified destination a const resolved = runtimeRoot({ getPath: () => pluginData }, legacy); assert.equal(resolved.root, legacy); assert.equal(resolved.modern, false); - assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v1.json")), false); + assert.equal(fs.existsSync(path.join(pluginData, ".format-converter-runtime-migration-v2.json")), false); } finally { fs.rmSync(base, { recursive: true, force: true }); } });