diff --git a/app/main/project/optimizeImageImport.lib.mjs b/app/main/project/optimizeImageImport.lib.mjs index bbbb393..cb10a48 100644 --- a/app/main/project/optimizeImageImport.lib.mjs +++ b/app/main/project/optimizeImageImport.lib.mjs @@ -2,7 +2,7 @@ * Visually lossless re-encode for imported raster images (same pixel dimensions). * Node-only; shared by the main app and ../project-converter (monorepo sibling). */ -import sharp from 'sharp'; +import { getSharp } from './sharpRuntime.mjs'; /** @typedef {import('node:buffer').Buffer} Buffer */ @@ -102,6 +102,7 @@ function makePassthrough(buf, meta) { * @param {number} h0 */ async function sameDimensionsOrThrow(outBuf, w0, h0) { + const sharp = getSharp(); const m = await sharp(outBuf).metadata(); if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) { const err = new Error('encode changed dimensions'); @@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) { return makePassthrough(input, { width: 0, height: 0, format: 'png' }); } + let sharp; + try { + sharp = getSharp(); + } catch { + return makePassthrough(input, null); + } + let meta0; try { meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata(); diff --git a/app/main/project/scenePreviewThumbnail.ts b/app/main/project/scenePreviewThumbnail.ts index b075548..76832f1 100644 --- a/app/main/project/scenePreviewThumbnail.ts +++ b/app/main/project/scenePreviewThumbnail.ts @@ -5,7 +5,8 @@ import path from 'node:path'; import { promisify } from 'node:util'; import ffmpegStatic from 'ffmpeg-static'; -import sharp from 'sharp'; + +import { getSharp } from './sharpRuntime.mjs'; const execFileAsync = promisify(execFile); @@ -21,6 +22,7 @@ export async function generateScenePreviewThumbnailBytes( kind: 'image' | 'video', ): Promise { try { + const sharp = getSharp(); if (kind === 'image') { return await sharp(source) .rotate() diff --git a/app/main/project/sharpRuntime.mjs b/app/main/project/sharpRuntime.mjs new file mode 100644 index 0000000..cd5e9a0 --- /dev/null +++ b/app/main/project/sharpRuntime.mjs @@ -0,0 +1,51 @@ +/** + * Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time. + * Call only from image-processing paths; errors are recoverable for the rest of the app. + */ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +/** @type {typeof import('sharp') | null} */ +let cached = null; +/** @type {Error | null} */ +let loadError = null; + +/** + * @param {unknown} err + * @returns {Error} + */ +export function sharpLoadFailure(err) { + const detail = err instanceof Error ? err.message : String(err); + return new Error( + [ + 'Не удалось загрузить модуль обработки изображений (sharp).', + 'Переустановите приложение полностью (удалите и поставьте заново)', + 'или исключите папку установки из проверки антивируса.', + detail ? `Детали: ${detail}` : '', + ] + .filter(Boolean) + .join(' '), + ); +} + +/** + * @returns {typeof import('sharp')} + */ +export function getSharp() { + if (cached) return cached; + if (loadError) throw loadError; + try { + cached = require('sharp'); + return cached; + } catch (err) { + loadError = sharpLoadFailure(err); + throw loadError; + } +} + +/** Reset cache (tests only). */ +export function __resetSharpRuntimeForTests() { + cached = null; + loadError = null; +} diff --git a/app/main/project/sharpRuntime.test.mjs b/app/main/project/sharpRuntime.test.mjs new file mode 100644 index 0000000..180241c --- /dev/null +++ b/app/main/project/sharpRuntime.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + __resetSharpRuntimeForTests, + getSharp, + sharpLoadFailure, +} from './sharpRuntime.mjs'; + +void test('getSharp: loads sharp when install is healthy', () => { + __resetSharpRuntimeForTests(); + const sharp = getSharp(); + assert.equal(typeof sharp, 'function'); +}); + +void test('sharpLoadFailure: includes reinstall hint', () => { + const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input')); + assert.match(err.message, /переустановите/i); + assert.match(err.message, /SyntaxError/); +}); diff --git a/package.json b/package.json index 1409bf2..6ba3ac1 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,15 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package", "release:info": "node scripts/print-release-info.mjs", - "pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder", - "pack:dir": "npm run build && node scripts/release-win-prep.mjs && electron-builder --dir", - "pack:mac": "npm run build && node scripts/release-mac-prep.mjs && electron-builder --mac", - "pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win", + "pack": "npm run build && node scripts/release-win-prep.mjs && electron-builder && node scripts/verify-packaged-sharp.mjs", + "pack:dir": "npm run build && node scripts/release-win-prep.mjs && electron-builder --dir && node scripts/verify-packaged-sharp.mjs", + "pack:mac": "npm run build && node scripts/release-mac-prep.mjs && electron-builder --mac && node scripts/verify-packaged-sharp.mjs", + "pack:win": "npm run build && node scripts/release-win-prep.mjs && electron-builder --win && node scripts/verify-packaged-sharp.mjs", "pack:linux": "node scripts/release-linux-pack.mjs", "release": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release.ps1", "release:all": "powershell -ExecutionPolicy Bypass -File scripts/ttrpg-release/release-all.ps1", diff --git a/scripts/release-linux-pack.mjs b/scripts/release-linux-pack.mjs index 0672e10..762ce09 100644 --- a/scripts/release-linux-pack.mjs +++ b/scripts/release-linux-pack.mjs @@ -71,3 +71,4 @@ run('npm', ['run', 'build']); ensureReleaseNativeDeps(projectRoot, 'linux'); run('electron-builder', ['--linux']); normalizeLinuxReleaseNames(); +run('node', [path.join(projectRoot, 'scripts', 'verify-packaged-sharp.mjs')]); diff --git a/scripts/verify-packaged-sharp.mjs b/scripts/verify-packaged-sharp.mjs new file mode 100644 index 0000000..b33efe6 --- /dev/null +++ b/scripts/verify-packaged-sharp.mjs @@ -0,0 +1,192 @@ +/** + * After electron-builder: fail the pack if sharp / @img look missing or truncated + * in release unpacked dirs. Catches AV quarantine and incomplete asarUnpack before shipping. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * @param {string} startDir + * @returns {string | null} + */ +function findAsarUnpackedNodeModules(startDir) { + const direct = path.join(startDir, 'resources', 'app.asar.unpacked', 'node_modules'); + if (fs.existsSync(direct)) return direct; + + // macOS: *.app/Contents/Resources/app.asar.unpacked/node_modules + if (!fs.existsSync(startDir)) return null; + for (const name of fs.readdirSync(startDir)) { + if (!name.endsWith('.app')) continue; + const macNm = path.join( + startDir, + name, + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules', + ); + if (fs.existsSync(macNm)) return macNm; + } + return null; +} + +/** + * @param {string} filePath + * @param {number} minBytes + */ +function assertJsLooksIntact(filePath, minBytes) { + if (!fs.existsSync(filePath)) { + throw new Error(`[verify-packaged-sharp] missing: ${filePath}`); + } + const st = fs.statSync(filePath); + if (st.size < minBytes) { + throw new Error( + `[verify-packaged-sharp] truncated (${st.size} B < ${minBytes}): ${filePath}`, + ); + } + const head = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' }).slice(0, 120); + const trimmed = head.trimStart(); + const ok = + trimmed.startsWith("'use strict'") || + trimmed.startsWith('"use strict"') || + head.includes('require(') || + head.includes('module.exports'); + if (!ok) { + throw new Error( + `[verify-packaged-sharp] sharp entry does not look like JS (corrupt?): ${filePath}`, + ); + } +} + +/** + * @param {string} dir + */ +function assertHasNativeBinary(dir) { + if (!fs.existsSync(dir)) { + throw new Error(`[verify-packaged-sharp] missing native package dir: ${dir}`); + } + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + if (!cur) break; + for (const name of fs.readdirSync(cur)) { + const p = path.join(cur, name); + const st = fs.statSync(p); + if (st.isDirectory()) { + stack.push(p); + continue; + } + if ( + name.endsWith('.node') || + name.endsWith('.dll') || + name.endsWith('.dylib') || + /\.so(\.|$)/u.test(name) + ) { + if (st.size < 50_000) { + throw new Error( + `[verify-packaged-sharp] native binary too small (${st.size} B): ${p}`, + ); + } + return; + } + } + } + throw new Error(`[verify-packaged-sharp] no .node/.dll/.so under ${dir}`); +} + +/** + * @param {{ label: string; unpackedRoot: string; imgDirs: string[] }} probe + */ +export function verifyUnpackedSharp(probe) { + const unpackedNm = + findAsarUnpackedNodeModules(probe.unpackedRoot) ?? + path.join(probe.unpackedRoot, 'resources', 'app.asar.unpacked', 'node_modules'); + + const sharpIndex = path.join(unpackedNm, 'sharp', 'lib', 'index.js'); + // Real sharp/lib/index.js is typically several KB; empty/AV-quarantined files fail here. + assertJsLooksIntact(sharpIndex, 32); + + for (const img of probe.imgDirs) { + assertHasNativeBinary(path.join(unpackedNm, '@img', img)); + } +} + +/** @type {{ label: string; unpackedRoot: string; imgDirs: string[] }[]} */ +const PROBES = [ + { + label: 'win-unpacked', + unpackedRoot: path.join(root, 'release', 'win-unpacked'), + imgDirs: ['sharp-win32-x64'], + }, + { + label: 'mac-arm64', + unpackedRoot: path.join(root, 'release', 'mac-arm64'), + imgDirs: ['sharp-darwin-arm64'], + }, + { + label: 'mac', + unpackedRoot: path.join(root, 'release', 'mac'), + imgDirs: ['sharp-darwin-x64', 'sharp-darwin-arm64'], + }, + { + label: 'linux-unpacked', + unpackedRoot: path.join(root, 'release', 'linux-unpacked'), + imgDirs: ['sharp-linux-x64', 'sharp-linux-arm64'], + }, +]; + +/** + * Verify every existing unpacked release dir. At least one must exist. + * For multi-arch probes, require at least one listed @img package that is present. + */ +export function verifyPackagedSharpRelease(releaseRoot = root) { + const probes = PROBES.map((p) => ({ + ...p, + unpackedRoot: path.join( + releaseRoot, + path.relative(root, p.unpackedRoot), + ), + })); + const existing = probes.filter((p) => fs.existsSync(p.unpackedRoot)); + if (existing.length === 0) { + throw new Error( + '[verify-packaged-sharp] no release unpacked dir found — run electron-builder first', + ); + } + + for (const probe of existing) { + const nm = findAsarUnpackedNodeModules(probe.unpackedRoot); + if (!nm) { + throw new Error( + `[verify-packaged-sharp] app.asar.unpacked/node_modules missing under ${probe.unpackedRoot}`, + ); + } + const presentImg = probe.imgDirs.filter((d) => + fs.existsSync(path.join(nm, '@img', d)), + ); + if (presentImg.length === 0) { + throw new Error( + `[verify-packaged-sharp] none of @img/{${probe.imgDirs.join(',')}} under ${nm}`, + ); + } + verifyUnpackedSharp({ ...probe, imgDirs: presentImg }); + console.log(`[verify-packaged-sharp] OK: ${probe.label}`); + } +} + +function main() { + verifyPackagedSharpRelease(); +} + +const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''; +if (import.meta.url === entry) { + try { + main(); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/scripts/verify-packaged-sharp.test.mjs b/scripts/verify-packaged-sharp.test.mjs new file mode 100644 index 0000000..08be5a2 --- /dev/null +++ b/scripts/verify-packaged-sharp.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { verifyUnpackedSharp } from './verify-packaged-sharp.mjs'; + +void test('verifyUnpackedSharp: accepts intact layout', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-')); + try { + const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules'); + const sharpLib = path.join(nm, 'sharp', 'lib'); + fs.mkdirSync(sharpLib, { recursive: true }); + fs.writeFileSync( + path.join(sharpLib, 'index.js'), + "'use strict';\nmodule.exports = require('./constructor');\n", + 'utf8', + ); + const imgDir = path.join(nm, '@img', 'sharp-win32-x64'); + fs.mkdirSync(imgDir, { recursive: true }); + fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1)); + + verifyUnpackedSharp({ + label: 'fixture', + unpackedRoot: root, + imgDirs: ['sharp-win32-x64'], + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +void test('verifyUnpackedSharp: rejects truncated sharp index', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-')); + try { + const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules'); + const sharpLib = path.join(nm, 'sharp', 'lib'); + fs.mkdirSync(sharpLib, { recursive: true }); + fs.writeFileSync(path.join(sharpLib, 'index.js'), 'x', 'utf8'); + const imgDir = path.join(nm, '@img', 'sharp-win32-x64'); + fs.mkdirSync(imgDir, { recursive: true }); + fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1)); + + assert.throws( + () => + verifyUnpackedSharp({ + label: 'fixture', + unpackedRoot: root, + imgDirs: ['sharp-win32-x64'], + }), + /truncated|corrupt/i, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +void test('verifyUnpackedSharp: accepts mac .app layout', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-mac-')); + try { + const nm = path.join( + root, + 'TTRPGPlayer.app', + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules', + ); + const sharpLib = path.join(nm, 'sharp', 'lib'); + fs.mkdirSync(sharpLib, { recursive: true }); + fs.writeFileSync( + path.join(sharpLib, 'index.js'), + "'use strict';\nmodule.exports = {};\n", + 'utf8', + ); + const imgDir = path.join(nm, '@img', 'sharp-darwin-arm64'); + fs.mkdirSync(imgDir, { recursive: true }); + fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1)); + + verifyUnpackedSharp({ + label: 'mac-fixture', + unpackedRoot: root, + imgDirs: ['sharp-darwin-arm64'], + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});