From f59e45808b9c707167369dc92472be0379ba3dcd Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 3 Aug 2026 14:09:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(image-batch-studio):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Windows=20=E5=A4=9A=E6=9E=B6=E6=9E=84=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固化 macOS 与 Windows 四套 Sharp 原生运行时,补充跨平台构建校验与发布 smoke 测试。 AI-Co-Authored-By: Codex --- plugins/image-batch-studio/CHANGELOG.md | 8 +- plugins/image-batch-studio/README.md | 6 +- plugins/image-batch-studio/package.json | 3 +- plugins/image-batch-studio/plugin.json | 1 + .../scripts/prepare-dist.mjs | 47 ++- .../scripts/sharp-runtime-targets.json | 76 +--- .../scripts/smoke-dist-runtime.mjs | 19 +- .../scripts/smoke-installed.mjs | 10 +- .../scripts/verify-dist-size.mjs | 38 -- .../scripts/verify-sharp-runtimes.mjs | 82 +++-- .../src/preload/file-discovery.ts | 2 +- .../src/preload/processor.ts | 3 +- .../src/preload/services.ts | 42 +-- .../src/preload/sharp-runtime.ts | 338 ------------------ .../image-batch-studio/src/shared/types.ts | 17 - plugins/image-batch-studio/src/ui/App.tsx | 90 +---- plugins/image-batch-studio/src/ui/styles.css | 77 ---- plugins/image-batch-studio/src/ui/ztools.d.ts | 3 - .../tests/gif-source-limit.test.ts | 4 +- .../tests/merge-memory.test.ts | 4 +- .../tests/process-limits.test.ts | 4 +- .../tests/processor.test.ts | 2 +- .../tests/sharp-runtime.test.ts | 32 -- .../tests/watermark-source-limit.test.ts | 4 +- .../tests/windows-support.test.ts | 13 +- 25 files changed, 134 insertions(+), 791 deletions(-) delete mode 100644 plugins/image-batch-studio/scripts/verify-dist-size.mjs delete mode 100644 plugins/image-batch-studio/src/preload/sharp-runtime.ts delete mode 100644 plugins/image-batch-studio/tests/sharp-runtime.test.ts diff --git a/plugins/image-batch-studio/CHANGELOG.md b/plugins/image-batch-studio/CHANGELOG.md index a6ab0ba72..7151f7624 100644 --- a/plugins/image-batch-studio/CHANGELOG.md +++ b/plugins/image-batch-studio/CHANGELOG.md @@ -1,12 +1,12 @@ # Changelog -## v0.1.1 - 2026-07-29 +## v0.1.1 - 2026-07-28 -- 新增 Windows x64 与 ARM64 支持,首次使用时按当前系统架构安装 Sharp 运行组件。 +- 新增 Windows x64 与 ARM64 支持,发布包内置 macOS/Windows 四套 Sharp 原生运行时。 - 修复 EXIF 方向图片的尺寸计算、裁剪和缩放结果,避免多帧 GIF 在普通处理流程中静默丢帧。 - 修复图片水印缺少文件、超出画布、模块开关失效及命名模板二次替换问题。 -- 升级 Sharp 与 Vite,使用固定 URL、SHA-512 和原子安装固化动态运行组件。 -- 将插件压缩包缩减至约 8MB,并增加 15MB 体积门禁、动态运行时 smoke 和 Windows CI 验证。 +- 升级 Sharp 与 Vite,使用独立 lockfile 和 `npm ci` 固化发布运行时依赖。 +- 增加发布目录 smoke test、跨平台原生工件校验和 Windows CI 验证。 ## v0.1.0 - 2026-06-02 diff --git a/plugins/image-batch-studio/README.md b/plugins/image-batch-studio/README.md index 9d964f5f1..6d683cd77 100644 --- a/plugins/image-batch-studio/README.md +++ b/plugins/image-batch-studio/README.md @@ -9,7 +9,7 @@ ZTools 图片批处理插件,提供面向图片文件、PDF 文件和文件夹 ## 平台支持 - macOS:当前版本已支持 Apple Silicon M 系列(arm64)和 Intel(x64)。 -- Windows:支持 x64 与 ARM64,首次使用图片能力时按当前架构安装 Sharp 运行组件。 +- Windows:支持 x64 与 ARM64,发布包内置对应 Sharp 运行时。 ## 功能 @@ -42,7 +42,7 @@ ZTools 图片批处理插件,提供面向图片文件、PDF 文件和文件夹 - React + Vite 构建界面。 - TypeScript 编写 UI、预加载层和处理器。 -- Sharp 处理图片压缩、转换、裁剪、水印、拼图、圆角等能力;运行组件安装后可离线使用。 +- Sharp 处理图片压缩、转换、裁剪、水印、拼图、圆角等能力。 - pdf-lib 处理 PDF 合并。 - gifenc 处理 GIF 合成。 - 所有处理在 ZTools 插件本地预加载进程内完成。 @@ -67,4 +67,4 @@ npm run install:local npm run smoke:installed ``` -`verify:runtime` 会校验 macOS arm64/x64 与 Windows x64/ARM64 的固定下载地址、版本和 SHA-512,确认发布包未混入原生运行时。`verify:size` 会检查压缩产物不超过 EdgeOne 的 15MB 限制。`smoke:installed` 会在当前系统生成测试图片和 PDF,验证图片处理、拼图、GIF 合成、PDF 合并以及 ZTools 安装记录。 +`verify:runtime` 会检测打包产物是否包含 macOS arm64/x64 与 Windows x64/ARM64 的 Sharp 运行时,以及各平台原生文件的 ASAR 解包配置。`smoke:installed` 会在当前系统生成测试图片和 PDF,验证图片处理、拼图、GIF 合成、PDF 合并以及 ZTools 安装记录。 diff --git a/plugins/image-batch-studio/package.json b/plugins/image-batch-studio/package.json index 77f3ff948..28521710a 100644 --- a/plugins/image-batch-studio/package.json +++ b/plugins/image-batch-studio/package.json @@ -9,10 +9,9 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc -p tsconfig.preload.json --noEmit", - "build": "vite build && tsc -p tsconfig.preload.json && node scripts/prepare-dist.mjs && node scripts/verify-dist-size.mjs", + "build": "vite build && tsc -p tsconfig.preload.json && node scripts/prepare-dist.mjs", "install:local": "node scripts/install-local.mjs", "verify:runtime": "node scripts/verify-sharp-runtimes.mjs", - "verify:size": "node scripts/verify-dist-size.mjs", "smoke:dist": "node scripts/smoke-dist-runtime.mjs", "smoke:installed": "node scripts/smoke-installed.mjs" }, diff --git a/plugins/image-batch-studio/plugin.json b/plugins/image-batch-studio/plugin.json index 40d284e2c..32728d30c 100644 --- a/plugins/image-batch-studio/plugin.json +++ b/plugins/image-batch-studio/plugin.json @@ -7,6 +7,7 @@ "version": "0.1.1", "main": "index.html", "preload": "preload/services.js", + "unpack": "@(preload/node_modules/**/*.node|preload/node_modules/**/*.dll|preload/node_modules/**/*.dylib)", "logo": "logo.svg", "platform": ["darwin", "win32"], "categories": ["media"], diff --git a/plugins/image-batch-studio/scripts/prepare-dist.mjs b/plugins/image-batch-studio/scripts/prepare-dist.mjs index 34aec3b81..e7e16a331 100644 --- a/plugins/image-batch-studio/scripts/prepare-dist.mjs +++ b/plugins/image-batch-studio/scripts/prepare-dist.mjs @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -11,6 +12,7 @@ const runtimeDir = path.join(root, "scripts", "runtime"); const runtimeConfig = JSON.parse( await fs.readFile(path.join(root, "scripts", "sharp-runtime-targets.json"), "utf8") ); +const [primaryTarget, ...additionalTargets] = runtimeConfig.targets; const runtimePackage = JSON.parse(await fs.readFile(path.join(runtimeDir, "package.json"), "utf8")); const runtimeDependencies = runtimePackage.dependencies; if (runtimeDependencies.sharp !== runtimeConfig.sharpVersion) { @@ -18,17 +20,18 @@ if (runtimeDependencies.sharp !== runtimeConfig.sharpVersion) { } const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -function npmCi(directory) { +function npmCi(directory, target) { execFileSync(npmCommand, [ "ci", "--omit=dev", - "--omit=optional", + "--include=optional", + `--os=${target.os}`, + `--cpu=${target.cpu}`, "--no-audit", "--no-fund" ], { cwd: directory, - stdio: "inherit", - shell: process.platform === "win32" + stdio: "inherit" }); } @@ -37,6 +40,35 @@ async function copyRuntimeManifest(directory) { await fs.copyFile(path.join(runtimeDir, "package-lock.json"), path.join(directory, "package-lock.json")); } +async function installSharpRuntime(target) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `ztools-sharp-${target.os}-${target.cpu}-`)); + try { + await copyRuntimeManifest(tempDir); + npmCi(tempDir, target); + + const sourceImgDir = path.join(tempDir, "node_modules", "@img"); + const targetImgDir = path.join(preloadDir, "node_modules", "@img"); + + await fs.mkdir(targetImgDir, { recursive: true }); + for (const runtimePackage of target.packages) { + const sourcePackage = path.join(sourceImgDir, runtimePackage.name); + await fs.access(sourcePackage); + await fs.cp(sourcePackage, path.join(targetImgDir, runtimePackage.name), { + recursive: true, + force: true + }); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function installAdditionalSharpRuntimes() { + for (const target of additionalTargets) { + await installSharpRuntime(target); + } +} + await fs.mkdir(preloadDir, { recursive: true }); await fs.mkdir(sharedDir, { recursive: true }); const pluginConfig = JSON.parse(await fs.readFile(path.join(root, "plugin.json"), "utf8")); @@ -45,10 +77,7 @@ await fs.writeFile(path.join(dist, "plugin.json"), `${JSON.stringify(pluginConfi await fs.copyFile(path.join(root, "logo.svg"), path.join(dist, "logo.svg")); await fs.copyFile(path.join(root, "cover.png"), path.join(dist, "cover.png")); await copyRuntimeManifest(preloadDir); -await fs.copyFile( - path.join(root, "scripts", "sharp-runtime-targets.json"), - path.join(preloadDir, "sharp-runtime-targets.json") -); await fs.writeFile(path.join(sharedDir, "package.json"), '{"type":"commonjs"}\n'); -npmCi(preloadDir); +npmCi(preloadDir, primaryTarget); +await installAdditionalSharpRuntimes(); diff --git a/plugins/image-batch-studio/scripts/sharp-runtime-targets.json b/plugins/image-batch-studio/scripts/sharp-runtime-targets.json index f18dd0128..f6e883e39 100644 --- a/plugins/image-batch-studio/scripts/sharp-runtime-targets.json +++ b/plugins/image-batch-studio/scripts/sharp-runtime-targets.json @@ -2,81 +2,33 @@ "sharpVersion": "0.35.3", "targets": [ { - "platform": "darwin", - "arch": "arm64", + "os": "darwin", + "cpu": "arm64", "packages": [ - { - "name": "@img/sharp-darwin-arm64", - "version": "0.35.3", - "url": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", - "size": 110055, - "artifacts": [".node"] - }, - { - "name": "@img/sharp-libvips-darwin-arm64", - "version": "1.3.2", - "url": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", - "size": 8254556, - "artifacts": [".dylib"] - } + { "name": "sharp-darwin-arm64", "artifacts": [".node"] }, + { "name": "sharp-libvips-darwin-arm64", "artifacts": [".dylib"] } ] }, { - "platform": "darwin", - "arch": "x64", + "os": "darwin", + "cpu": "x64", "packages": [ - { - "name": "@img/sharp-darwin-x64", - "version": "0.35.3", - "url": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", - "size": 114713, - "artifacts": [".node"] - }, - { - "name": "@img/sharp-libvips-darwin-x64", - "version": "1.3.2", - "url": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", - "size": 8838419, - "artifacts": [".dylib"] - } + { "name": "sharp-darwin-x64", "artifacts": [".node"] }, + { "name": "sharp-libvips-darwin-x64", "artifacts": [".dylib"] } ] }, { - "platform": "win32", - "arch": "x64", + "os": "win32", + "cpu": "x64", "packages": [ - { - "name": "@img/sharp-win32-x64", - "version": "0.35.3", - "url": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", - "size": 8434051, - "artifacts": [".node", ".dll"] - } + { "name": "sharp-win32-x64", "artifacts": [".node", ".dll"] } ] }, { - "platform": "win32", - "arch": "arm64", + "os": "win32", + "cpu": "arm64", "packages": [ - { - "name": "@img/sharp-win32-arm64", - "version": "0.35.3", - "url": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "fallbackUrl": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", - "size": 7785250, - "artifacts": [".node", ".dll"] - } + { "name": "sharp-win32-arm64", "artifacts": [".node", ".dll"] } ] } ] diff --git a/plugins/image-batch-studio/scripts/smoke-dist-runtime.mjs b/plugins/image-batch-studio/scripts/smoke-dist-runtime.mjs index 3b3560517..a4af40401 100644 --- a/plugins/image-batch-studio/scripts/smoke-dist-runtime.mjs +++ b/plugins/image-batch-studio/scripts/smoke-dist-runtime.mjs @@ -7,13 +7,9 @@ import { fileURLToPath } from "node:url"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const processorPath = path.join(root, "dist", "preload", "processor.js"); const requireDist = createRequire(processorPath); -const directory = await fs.mkdtemp(path.join(os.tmpdir(), "image-batch-dist-smoke-")); -process.env.IMAGE_BATCH_RUNTIME_ROOT = path.join(directory, "runtime"); -const runtime = requireDist("./sharp-runtime.js"); -const runtimeStatus = await runtime.installSharpRuntime(); -if (runtimeStatus.state !== "ready") throw new Error(runtimeStatus.error || "Dynamic Sharp runtime installation failed"); const processor = requireDist(processorPath); -const sharp = runtime.getSharp(); +const sharp = requireDist("sharp"); +const directory = await fs.mkdtemp(path.join(os.tmpdir(), "image-batch-dist-smoke-")); try { const inputPath = path.join(directory, "input.png"); @@ -40,14 +36,7 @@ try { throw new Error(`Unexpected dist output metadata: ${JSON.stringify(metadata)}`); } - console.log( - JSON.stringify( - { ok: true, platform: process.platform, arch: process.arch, runtime: runtimeStatus.version }, - null, - 2 - ) - ); + console.log(JSON.stringify({ ok: true, platform: process.platform, arch: process.arch }, null, 2)); } finally { - sharp.cache(false); - await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await fs.rm(directory, { recursive: true, force: true }); } diff --git a/plugins/image-batch-studio/scripts/smoke-installed.mjs b/plugins/image-batch-studio/scripts/smoke-installed.mjs index 83f9e3c4b..74c0e83c6 100644 --- a/plugins/image-batch-studio/scripts/smoke-installed.mjs +++ b/plugins/image-batch-studio/scripts/smoke-installed.mjs @@ -18,13 +18,6 @@ const installedPlugin = path.join(dataRoot, "plugins", "image-batch-studio"); const dbPath = modernLayout ? path.join(dataRoot, "lmdb", "device") : path.join(dataRoot, "lmdb"); -const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ztools-image-batch-installed-")); -process.env.IMAGE_BATCH_RUNTIME_ROOT = path.join(dir, "runtime"); -const runtime = require(path.join(installedPlugin, "preload", "sharp-runtime.js")); -const runtimeStatus = await runtime.installSharpRuntime(); -if (runtimeStatus.state !== "ready") { - throw new Error(runtimeStatus.error || "Installed dynamic Sharp runtime failed"); -} const processor = require(path.join(installedPlugin, "preload", "processor.js")); function readInstalledPluginRecord() { @@ -80,6 +73,7 @@ async function makePdf(filePath, text) { await fs.writeFile(filePath, await doc.save()); } +const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ztools-image-batch-installed-")); const out = path.join(dir, "out"); const first = path.join(dir, "first.png"); const second = path.join(dir, "second.png"); @@ -170,4 +164,4 @@ if (!installedRecord.features?.some((feature) => feature.code === "image-batch") throw new Error(`Installed plugin record is missing image-batch feature: ${JSON.stringify(installedRecord)}`); } -console.log(JSON.stringify({ ok: true, dir, runtime: runtimeStatus.version }, null, 2)); +console.log(JSON.stringify({ ok: true, dir }, null, 2)); diff --git a/plugins/image-batch-studio/scripts/verify-dist-size.mjs b/plugins/image-batch-studio/scripts/verify-dist-size.mjs deleted file mode 100644 index 671bb2417..000000000 --- a/plugins/image-batch-studio/scripts/verify-dist-size.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import zlib from "node:zlib"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; - -const deflateRaw = promisify(zlib.deflateRaw); -const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const dist = path.join(root, "dist"); -const maxBytes = 15_000_000; -const estimationLimit = 14_500_000; - -async function filesIn(directory) { - const entries = await fs.readdir(directory, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) files.push(...(await filesIn(entryPath))); - else if (entry.isFile()) files.push(entryPath); - } - return files; -} - -let estimatedZipBytes = 22; -for (const filePath of await filesIn(dist)) { - const relativePath = path.relative(dist, filePath).split(path.sep).join("/"); - const content = await fs.readFile(filePath); - const compressed = await deflateRaw(content, { level: 9 }); - estimatedZipBytes += compressed.byteLength + 76 + Buffer.byteLength(relativePath) * 2; -} - -if (estimatedZipBytes > estimationLimit) { - throw new Error( - `Estimated plugin ZIP is ${(estimatedZipBytes / 1_000_000).toFixed(2)}MB, exceeding the 14.5MB safety budget for EdgeOne's 15MB limit` - ); -} - -console.log(JSON.stringify({ ok: true, estimatedZipBytes, estimationLimit, maxBytes }, null, 2)); diff --git a/plugins/image-batch-studio/scripts/verify-sharp-runtimes.mjs b/plugins/image-batch-studio/scripts/verify-sharp-runtimes.mjs index 26f313659..07d8a1c22 100644 --- a/plugins/image-batch-studio/scripts/verify-sharp-runtimes.mjs +++ b/plugins/image-batch-studio/scripts/verify-sharp-runtimes.mjs @@ -3,65 +3,75 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const distPreload = path.join(root, "dist", "preload"); +const imgModules = path.join(root, "dist", "preload", "node_modules", "@img"); +const manifest = JSON.parse(await fs.readFile(path.join(root, "dist", "plugin.json"), "utf8")); const runtimeConfig = JSON.parse( await fs.readFile(path.join(root, "scripts", "sharp-runtime-targets.json"), "utf8") ); -const runtimeLock = JSON.parse( - await fs.readFile(path.join(root, "scripts", "runtime", "package-lock.json"), "utf8") -); -const manifest = JSON.parse(await fs.readFile(path.join(root, "dist", "plugin.json"), "utf8")); + +async function containsArtifact(directory, extension) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory() && (await containsArtifact(entryPath, extension))) return true; + if (entry.isFile() && entry.name.endsWith(extension)) return true; + } + return false; +} + +const missing = []; const sharpPackage = JSON.parse( - await fs.readFile(path.join(distPreload, "node_modules", "sharp", "package.json"), "utf8") + await fs.readFile(path.join(root, "dist", "preload", "node_modules", "sharp", "package.json"), "utf8") ); -const errors = []; - if (sharpPackage.version !== runtimeConfig.sharpVersion) { - errors.push(`sharp version ${sharpPackage.version} (expected ${runtimeConfig.sharpVersion})`); + missing.push(`sharp version ${sharpPackage.version} (expected ${runtimeConfig.sharpVersion})`); } - for (const target of runtimeConfig.targets) { for (const runtimePackage of target.packages) { - const lockEntry = runtimeLock.packages[`node_modules/${runtimePackage.name}`]; - if (!lockEntry) { - errors.push(`${runtimePackage.name} missing from runtime lockfile`); - continue; - } - if (lockEntry.version !== runtimePackage.version) { - errors.push(`${runtimePackage.name} version ${runtimePackage.version} (lockfile ${lockEntry.version})`); - } - if (lockEntry.integrity !== runtimePackage.integrity) { - errors.push(`${runtimePackage.name} integrity mismatch`); - } - if (lockEntry.resolved !== runtimePackage.url) { - errors.push(`${runtimePackage.name} URL mismatch`); - } - if (!runtimePackage.url.startsWith("https://") || !runtimePackage.integrity.startsWith("sha512-")) { - errors.push(`${runtimePackage.name} must use HTTPS and SHA-512`); + const packageDirectory = path.join(imgModules, runtimePackage.name); + try { + const packageJson = JSON.parse(await fs.readFile(path.join(packageDirectory, "package.json"), "utf8")); + if (!packageJson.os?.includes(target.os) || !packageJson.cpu?.includes(target.cpu)) { + missing.push(`${runtimePackage.name} metadata for ${target.os}/${target.cpu}`); + continue; + } + for (const extension of runtimePackage.artifacts) { + if (!(await containsArtifact(packageDirectory, extension))) { + missing.push(`${runtimePackage.name} ${extension}`); + } + } + } catch { + missing.push(runtimePackage.name); } } } -const bundledImgPackages = await fs.readdir(path.join(distPreload, "node_modules", "@img")).catch(() => []); -const bundledNative = bundledImgPackages.filter((name) => name.startsWith("sharp-")); -if (bundledNative.length > 0) { - errors.push(`native Sharp packages must be downloaded at runtime: ${bundledNative.join(", ")}`); +if (missing.length > 0) { + throw new Error(`Missing Sharp runtime artifacts: ${missing.join(", ")}`); } -if (manifest.unpack) errors.push("plugin.json must not unpack runtime-downloaded native files"); for (const platform of ["darwin", "win32"]) { - if (!manifest.platform?.includes(platform)) errors.push(`plugin.json must enable ${platform}`); + if (!manifest.platform?.includes(platform)) { + throw new Error(`plugin.json must enable ${platform}`); + } } -if (errors.length > 0) throw new Error(`Sharp runtime verification failed: ${errors.join("; ")}`); +for (const extension of [".node", ".dll", ".dylib"]) { + if (!manifest.unpack?.includes(extension)) { + throw new Error(`plugin.json unpack must include ${extension} native artifacts`); + } +} + +if (!manifest.unpack.includes("preload/node_modules")) { + throw new Error("plugin.json unpack must target preload/node_modules"); +} console.log( JSON.stringify( { ok: true, - sharp: runtimeConfig.sharpVersion, - targets: runtimeConfig.targets.map((target) => `${target.platform}/${target.arch}`), - bundledNative: false + targets: runtimeConfig.targets.map((target) => `${target.os}/${target.cpu}`), + unpack: manifest.unpack }, null, 2 diff --git a/plugins/image-batch-studio/src/preload/file-discovery.ts b/plugins/image-batch-studio/src/preload/file-discovery.ts index f3d18bd22..44499600d 100644 --- a/plugins/image-batch-studio/src/preload/file-discovery.ts +++ b/plugins/image-batch-studio/src/preload/file-discovery.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import sharp from "sharp"; import type { SourceFile } from "../shared/types"; -import { sharp } from "./sharp-runtime"; const imageExtensions = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".heif", ".heic", ".tif", ".tiff", ".gif"]); const pdfExtensions = new Set([".pdf"]); diff --git a/plugins/image-batch-studio/src/preload/processor.ts b/plugins/image-batch-studio/src/preload/processor.ts index c9f53134c..ab091e9d5 100644 --- a/plugins/image-batch-studio/src/preload/processor.ts +++ b/plugins/image-batch-studio/src/preload/processor.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OverlayOptions, Sharp } from "sharp"; +import sharp, { type OverlayOptions, type Sharp } from "sharp"; import { PDFDocument } from "pdf-lib"; import { GIFEncoder, applyPalette, quantize } from "gifenc"; import type { @@ -24,7 +24,6 @@ import { maxMergeSourcePixels, maxProcessSourcePixels } from "./processing-limits"; -import { sharp } from "./sharp-runtime"; const imageExtensions = new Set(["jpg", "jpeg", "png", "webp", "avif", "heif", "heic", "tif", "tiff", "gif"]); diff --git a/plugins/image-batch-studio/src/preload/services.ts b/plugins/image-batch-studio/src/preload/services.ts index c1314b1b9..017a905ca 100644 --- a/plugins/image-batch-studio/src/preload/services.ts +++ b/plugins/image-batch-studio/src/preload/services.ts @@ -5,16 +5,11 @@ import type { GifOptions, ImageJobSettings, MergeImagesOptions, - SharpRuntimeProgress, SourceFile } from "../shared/types"; import { imageDataUrlToBuffer } from "./data-url"; import { createGif, mergeImages, mergePdfs, processImages } from "./processor"; import { discoverFiles } from "./file-discovery"; -import { - installSharpRuntime, - sharpRuntimeStatus -} from "./sharp-runtime"; declare global { interface Window { @@ -57,28 +52,9 @@ async function imagePayloadToFile(payload: unknown): Promise { async function resolveLaunchFiles(action: any): Promise { const directPaths = payloadPaths(action?.payload); const imagePaths = action?.type === "img" ? await imagePayloadToFile(action?.payload) : []; - if (directPaths.length > 0 || imagePaths.length > 0) await ensureSharpRuntime(); return discoverFiles([...directPaths, ...imagePaths]); } -function dispatchRuntimeProgress(progress: SharpRuntimeProgress) { - window.dispatchEvent(new CustomEvent("image-batch-runtime-progress", { detail: progress })); -} - -async function ensureSharpRuntime() { - const current = await sharpRuntimeStatus(); - if (current.state === "ready") return current; - if (current.state === "unsupported") { - throw new Error(`当前平台暂不支持图像运行组件:${current.target}`); - } - const installed = await installSharpRuntime(dispatchRuntimeProgress); - window.dispatchEvent(new CustomEvent("image-batch-runtime-status", { detail: installed })); - if (installed.state !== "ready") { - throw new Error(installed.error || "图像运行组件安装失败"); - } - return installed; -} - async function notifyEnter(action: any) { const files = await resolveLaunchFiles(action); if (files.length > 0) { @@ -92,22 +68,10 @@ const services = { }, async resolveFiles(paths: string[]) { - if (paths.length > 0) await ensureSharpRuntime(); return discoverFiles(paths); }, - runtimeStatus() { - return sharpRuntimeStatus(); - }, - - async installRuntime() { - const status = await installSharpRuntime(dispatchRuntimeProgress); - window.dispatchEvent(new CustomEvent("image-batch-runtime-status", { detail: status })); - return status; - }, - async processImages(paths: string[], settings: ImageJobSettings) { - await ensureSharpRuntime(); return processImages(paths, settings, (completed, total, result) => { window.dispatchEvent( new CustomEvent("image-batch-progress", { @@ -122,12 +86,10 @@ const services = { }, async mergeImages(paths: string[], outputPath: string, options: MergeImagesOptions) { - await ensureSharpRuntime(); return mergeImages(paths, outputPath, options); }, async createGif(paths: string[], outputPath: string, options: GifOptions) { - await ensureSharpRuntime(); return createGif(paths, outputPath, options); }, @@ -138,9 +100,7 @@ const services = { { name: "Images and PDFs", extensions: ["jpg", "jpeg", "png", "webp", "avif", "heif", "heic", "tiff", "gif", "pdf"] } ] }); - if (!paths?.length) return []; - await ensureSharpRuntime(); - return discoverFiles(paths); + return paths ? discoverFiles(paths) : []; }, async chooseDirectory() { diff --git a/plugins/image-batch-studio/src/preload/sharp-runtime.ts b/plugins/image-batch-studio/src/preload/sharp-runtime.ts deleted file mode 100644 index 5b5019373..000000000 --- a/plugins/image-batch-studio/src/preload/sharp-runtime.ts +++ /dev/null @@ -1,338 +0,0 @@ -import crypto from "node:crypto"; -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import https from "node:https"; -import os from "node:os"; -import path from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import type { SharpRuntimeProgress, SharpRuntimeStatus } from "../shared/types"; - -interface RuntimePackageConfig { - name: string; - version: string; - url: string; - fallbackUrl?: string; - integrity: string; - size: number; - artifacts: string[]; -} - -interface RuntimeTargetConfig { - platform: string; - arch: string; - packages: RuntimePackageConfig[]; -} - -interface RuntimeConfig { - sharpVersion: string; - targets: RuntimeTargetConfig[]; -} - -type SharpFactory = typeof import("sharp").default; - -const execFileAsync = promisify(execFile); -const maxDownloadBytes = 20 * 1024 * 1024; -let configCache: RuntimeConfig | undefined; -let sharpFactory: SharpFactory | undefined; -let installPromise: Promise | undefined; - -function configPath(): string { - const packaged = path.join(__dirname, "sharp-runtime-targets.json"); - return fsSync.existsSync(packaged) - ? packaged - : path.resolve(__dirname, "../../scripts/sharp-runtime-targets.json"); -} - -function runtimeConfig(): RuntimeConfig { - if (!configCache) { - configCache = JSON.parse(fsSync.readFileSync(configPath(), "utf8")) as RuntimeConfig; - } - return configCache; -} - -function targetKey(platform: string = process.platform, arch: string = process.arch): string { - return `${platform}-${arch}`; -} - -export function selectSharpRuntimeTarget( - config: RuntimeConfig, - platform = process.platform, - arch = process.arch -): RuntimeTargetConfig | undefined { - return config.targets.find((target) => target.platform === platform && target.arch === arch); -} - -function userDataPath(): string { - if (process.env.IMAGE_BATCH_RUNTIME_ROOT) return process.env.IMAGE_BATCH_RUNTIME_ROOT; - const ztools = typeof window !== "undefined" ? (window as any).ztools : undefined; - if (ztools?.getPath) { - return path.join(ztools.getPath("userData"), "image-batch-studio", "runtime"); - } - return path.join(os.tmpdir(), "image-batch-studio-runtime"); -} - -function targetRoot(config: RuntimeConfig, target: RuntimeTargetConfig): string { - return path.join(userDataPath(), config.sharpVersion, targetKey(target.platform, target.arch)); -} - -function targetNodeModules(config: RuntimeConfig, target: RuntimeTargetConfig): string { - return path.join(targetRoot(config, target), "node_modules"); -} - -function packageDirectory(root: string, packageName: string): string { - return path.join(root, "node_modules", ...packageName.split("/")); -} - -async function containsArtifact(directory: string, extension: string): Promise { - const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory() && (await containsArtifact(entryPath, extension))) return true; - if (entry.isFile() && entry.name.endsWith(extension)) return true; - } - return false; -} - -async function isInstalled(config: RuntimeConfig, target: RuntimeTargetConfig): Promise { - const root = targetRoot(config, target); - for (const runtimePackage of target.packages) { - const directory = packageDirectory(root, runtimePackage.name); - const packageJson = await fs - .readFile(path.join(directory, "package.json"), "utf8") - .then((value) => JSON.parse(value) as { name?: string; version?: string }) - .catch(() => undefined); - if (packageJson?.name !== runtimePackage.name || packageJson.version !== runtimePackage.version) return false; - for (const extension of runtimePackage.artifacts) { - if (!(await containsArtifact(directory, extension))) return false; - } - } - return true; -} - -function configureRuntimePath(config: RuntimeConfig, target: RuntimeTargetConfig): void { - const nodeModules = targetNodeModules(config, target); - const entries = (process.env.NODE_PATH ?? "").split(path.delimiter).filter(Boolean); - if (!entries.includes(nodeModules)) { - process.env.NODE_PATH = [nodeModules, ...entries].join(path.delimiter); - const Module = require("node:module") as { _initPaths?: () => void }; - Module._initPaths?.(); - } -} - -function tryLoadSharp(): SharpFactory | undefined { - if (sharpFactory) return sharpFactory; - try { - const candidate = require("sharp") as SharpFactory; - if (candidate.versions.sharp !== runtimeConfig().sharpVersion) return undefined; - sharpFactory = candidate; - return sharpFactory; - } catch { - return undefined; - } -} - -export function getSharp(): SharpFactory { - const loaded = tryLoadSharp(); - if (!loaded) throw new Error("图像运行组件尚未安装"); - return loaded; -} - -export const sharp = ((...args: unknown[]) => getSharp()(...(args as [never]))) as SharpFactory; - -function downloadBytes(target: RuntimeTargetConfig | undefined): number { - return target?.packages.reduce((sum, item) => sum + item.size, 0) ?? 0; -} - -export async function sharpRuntimeStatus(): Promise { - const config = runtimeConfig(); - const target = selectSharpRuntimeTarget(config); - const base = { - version: config.sharpVersion, - target: targetKey(), - downloadBytes: downloadBytes(target) - }; - if (!target) return { ...base, state: "unsupported" }; - if (tryLoadSharp()) return { ...base, state: "ready" }; - if (!(await isInstalled(config, target))) return { ...base, state: "missing" }; - configureRuntimePath(config, target); - return tryLoadSharp() - ? { ...base, state: "ready" } - : { ...base, state: "error", error: "运行组件已安装但无法加载" }; -} - -export function verifyRuntimeIntegrity(buffer: Buffer, integrity: string): boolean { - const separator = integrity.indexOf("-"); - if (separator <= 0) return false; - const algorithm = integrity.slice(0, separator); - const expected = Buffer.from(integrity.slice(separator + 1), "base64"); - const actual = crypto.createHash(algorithm).update(buffer).digest(); - return actual.length === expected.length && crypto.timingSafeEqual(actual, expected); -} - -async function downloadToFile( - url: string, - destination: string, - onChunk: (bytes: number) => void, - redirects = 5 -): Promise { - const parsed = new URL(url); - if (parsed.protocol !== "https:") throw new Error("运行组件下载地址必须使用 HTTPS"); - await new Promise((resolve, reject) => { - const request = https.get(parsed, (response) => { - if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { - response.resume(); - if (redirects <= 0) return reject(new Error("运行组件下载重定向次数过多")); - const nextUrl = new URL(response.headers.location, parsed).toString(); - downloadToFile(nextUrl, destination, onChunk, redirects - 1).then(resolve, reject); - return; - } - if (response.statusCode !== 200) { - response.resume(); - reject(new Error(`运行组件下载失败:HTTP ${response.statusCode ?? "unknown"}`)); - return; - } - let received = 0; - const output = fsSync.createWriteStream(destination, { flags: "wx" }); - response.on("data", (chunk: Buffer) => { - received += chunk.length; - if (received > maxDownloadBytes) request.destroy(new Error("运行组件下载文件过大")); - onChunk(chunk.length); - }); - response.pipe(output); - output.on("finish", () => output.close(() => resolve())); - output.on("error", reject); - response.on("error", reject); - }); - request.on("error", reject); - }); -} - -async function downloadPackage( - runtimePackage: RuntimePackageConfig, - archivePath: string, - onReceived: (bytes: number) => void -): Promise { - const urls = [runtimePackage.url, runtimePackage.fallbackUrl].filter((url): url is string => Boolean(url)); - let lastError: unknown; - for (const url of urls) { - await fs.rm(archivePath, { force: true }).catch(() => undefined); - let received = 0; - try { - await downloadToFile(url, archivePath, (bytes) => { - received += bytes; - onReceived(received); - }); - const buffer = await fs.readFile(archivePath); - if (!verifyRuntimeIntegrity(buffer, runtimePackage.integrity)) { - throw new Error(`运行组件校验失败:${runtimePackage.name}`); - } - return buffer; - } catch (error) { - lastError = error; - } - } - throw lastError instanceof Error ? lastError : new Error(`无法下载 ${runtimePackage.name}`); -} - -async function extractPackage(archivePath: string, destination: string): Promise { - const extractRoot = `${destination}-extract`; - await fs.mkdir(extractRoot, { recursive: true }); - try { - await execFileAsync("tar", ["-xzf", archivePath, "-C", extractRoot], { windowsHide: true }); - const extractedPackage = path.join(extractRoot, "package"); - await fs.access(path.join(extractedPackage, "package.json")); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.cp(extractedPackage, destination, { recursive: true, force: true }); - } finally { - await fs.rm(extractRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - } -} - -function emitProgress( - callback: ((progress: SharpRuntimeProgress) => void) | undefined, - phase: SharpRuntimeProgress["phase"], - loaded: number, - total: number -): void { - callback?.({ phase, loaded, total, percent: total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0 }); -} - -async function installRuntime( - onProgress?: (progress: SharpRuntimeProgress) => void -): Promise { - const existing = await sharpRuntimeStatus(); - if (existing.state === "ready" || existing.state === "unsupported") return existing; - - const config = runtimeConfig(); - const target = selectSharpRuntimeTarget(config); - if (!target) return existing; - const total = downloadBytes(target); - const runtimeParent = path.dirname(targetRoot(config, target)); - await fs.mkdir(runtimeParent, { recursive: true }); - const stagingRoot = await fs.mkdtemp(path.join(runtimeParent, ".install-")); - let loaded = 0; - - try { - for (const [index, runtimePackage] of target.packages.entries()) { - const archivePath = path.join(stagingRoot, `package-${index}.tgz`); - const packageStart = loaded; - const archive = await downloadPackage(runtimePackage, archivePath, (received) => { - emitProgress(onProgress, "downloading", packageStart + received, total); - }); - loaded += archive.byteLength; - emitProgress(onProgress, "verifying", loaded, total); - await extractPackage(archivePath, packageDirectory(stagingRoot, runtimePackage.name)); - await fs.rm(archivePath, { force: true }); - } - - emitProgress(onProgress, "installing", total, total); - if (!(await isInstalledAt(stagingRoot, target))) throw new Error("运行组件文件不完整"); - await fs.writeFile( - path.join(stagingRoot, "runtime.json"), - `${JSON.stringify({ version: config.sharpVersion, target: targetKey(target.platform, target.arch) }, null, 2)}\n` - ); - const finalRoot = targetRoot(config, target); - await fs.rm(finalRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - await fs.rename(stagingRoot, finalRoot); - configureRuntimePath(config, target); - if (!tryLoadSharp()) throw new Error("运行组件安装完成但无法加载"); - return { state: "ready", version: config.sharpVersion, target: targetKey(), downloadBytes: total }; - } catch (error) { - return { - state: "error", - version: config.sharpVersion, - target: targetKey(), - downloadBytes: total, - error: error instanceof Error ? error.message : String(error) - }; - } finally { - await fs.rm(stagingRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined); - } -} - -async function isInstalledAt(root: string, target: RuntimeTargetConfig): Promise { - for (const runtimePackage of target.packages) { - const directory = packageDirectory(root, runtimePackage.name); - const packageJson = await fs - .readFile(path.join(directory, "package.json"), "utf8") - .then((value) => JSON.parse(value) as { name?: string; version?: string }) - .catch(() => undefined); - if (packageJson?.name !== runtimePackage.name || packageJson.version !== runtimePackage.version) return false; - for (const extension of runtimePackage.artifacts) { - if (!(await containsArtifact(directory, extension))) return false; - } - } - return true; -} - -export function installSharpRuntime( - onProgress?: (progress: SharpRuntimeProgress) => void -): Promise { - if (!installPromise) { - installPromise = installRuntime(onProgress).finally(() => { - installPromise = undefined; - }); - } - return installPromise; -} diff --git a/plugins/image-batch-studio/src/shared/types.ts b/plugins/image-batch-studio/src/shared/types.ts index aca2d8c6a..317d0f1f2 100644 --- a/plugins/image-batch-studio/src/shared/types.ts +++ b/plugins/image-batch-studio/src/shared/types.ts @@ -21,23 +21,6 @@ export interface SourceFile { format?: string; } -export type SharpRuntimeState = "checking" | "missing" | "installing" | "ready" | "error" | "unsupported"; - -export interface SharpRuntimeStatus { - state: SharpRuntimeState; - version: string; - target: string; - downloadBytes: number; - error?: string; -} - -export interface SharpRuntimeProgress { - phase: "downloading" | "verifying" | "installing"; - loaded: number; - total: number; - percent: number; -} - export interface OutputSettings { directory: string; namingPattern: string; diff --git a/plugins/image-batch-studio/src/ui/App.tsx b/plugins/image-batch-studio/src/ui/App.tsx index 84411122e..2bae7d61e 100644 --- a/plugins/image-batch-studio/src/ui/App.tsx +++ b/plugins/image-batch-studio/src/ui/App.tsx @@ -8,7 +8,6 @@ import { ChevronsUpDown, Clipboard, Crop, - Download, FileArchive, FileImage, FilePlus2, @@ -34,8 +33,6 @@ import type { ImageJobSettings, MergeImagesOptions, ProcessResult, - SharpRuntimeProgress, - SharpRuntimeStatus, SourceFile, WatermarkPosition } from "../shared/types"; @@ -171,13 +168,6 @@ export function App() { const [dropActive, setDropActive] = useState(false); const [showFileTray, setShowFileTray] = useState(false); const [showResults, setShowResults] = useState(false); - const [runtimeStatus, setRuntimeStatus] = useState({ - state: "checking", - version: "", - target: "", - downloadBytes: 0 - }); - const [runtimeProgress, setRuntimeProgress] = useState(); const imageFiles = useMemo(() => files.filter((file) => file.type === "image"), [files]); const pdfFiles = useMemo(() => files.filter((file) => file.type === "pdf"), [files]); @@ -198,28 +188,11 @@ export function App() { const custom = event as CustomEvent<{ completed: number; total: number }>; setProgress(`${custom.detail.completed}/${custom.detail.total}`); }; - const onRuntimeProgress = (event: Event) => { - const custom = event as CustomEvent; - setRuntimeProgress(custom.detail); - setRuntimeStatus((current) => ({ ...current, state: "installing", error: undefined })); - }; - const onRuntimeStatus = (event: Event) => { - const custom = event as CustomEvent; - setRuntimeStatus(custom.detail); - if (custom.detail.state !== "installing") setRuntimeProgress(undefined); - }; window.addEventListener("image-batch-enter", onEnter); window.addEventListener("image-batch-progress", onProgress); - window.addEventListener("image-batch-runtime-progress", onRuntimeProgress); - window.addEventListener("image-batch-runtime-status", onRuntimeStatus); - window.services?.runtimeStatus?.().then(setRuntimeStatus).catch((error) => { - setRuntimeStatus((current) => ({ ...current, state: "error", error: errorMessage(error) })); - }); return () => { window.removeEventListener("image-batch-enter", onEnter); window.removeEventListener("image-batch-progress", onProgress); - window.removeEventListener("image-batch-runtime-progress", onRuntimeProgress); - window.removeEventListener("image-batch-runtime-status", onRuntimeStatus); }; }, []); @@ -249,27 +222,7 @@ export function App() { } async function chooseFiles() { - try { - addFiles(await window.services.chooseFiles()); - } catch (error) { - notify(errorMessage(error)); - } - } - - async function installRuntime() { - setRuntimeStatus((current) => ({ ...current, state: "installing", error: undefined })); - try { - const status = await window.services.installRuntime(); - setRuntimeStatus(status); - if (status.state === "ready") notify("图像运行组件已安装"); - else notify(status.error || "图像运行组件安装失败"); - } catch (error) { - const message = errorMessage(error); - setRuntimeStatus((current) => ({ ...current, state: "error", error: message })); - notify(message); - } finally { - setRuntimeProgress(undefined); - } + addFiles(await window.services.chooseFiles()); } async function chooseOutputDirectory() { @@ -295,11 +248,7 @@ export function App() { const paths = Array.from(event.dataTransfer.files) .map((file) => window.services.getPathForFile(file)) .filter(Boolean); - try { - addFiles(await window.services.resolveFiles(paths)); - } catch (error) { - notify(errorMessage(error)); - } + addFiles(await window.services.resolveFiles(paths)); } async function processSelectedImageModule() { @@ -653,27 +602,6 @@ export function App() { -
-