diff --git a/src/main/ImageOperations.js b/src/main/ImageOperations.js index 69052de..5d2918c 100644 --- a/src/main/ImageOperations.js +++ b/src/main/ImageOperations.js @@ -4,12 +4,41 @@ * Handles image manipulation via `sharp`: format conversion, resize, compress, rotate. * Mirrors the executeOperation(operation, data) dispatcher pattern used by PDFOperations.js. * + * sharp loads LAZILY: its native bindings (@img/sharp-*) are optionalDependencies + * that a packaged build can prune or fail to unpack, and a top-level require would + * then crash the whole app at boot (src/main.js requires this module unconditionally). + * When sharp cannot load, operations degrade honestly instead of killing the app — + * the same honest-failure precedent PDFOperations set for missing pdf-lib features. + * * @module ImageOperations */ const fs = require('fs'); const path = require('path'); -const sharp = require('sharp'); + +let sharpModule = null; +let sharpLoadError = null; + +function loadSharp() { + if (sharpModule) return sharpModule; + if (sharpLoadError) throw sharpLoadError; + try { + sharpModule = require('sharp'); + return sharpModule; + } catch (error) { + sharpLoadError = error; + throw error; + } +} + +// Strip absolute paths from error text before it reaches callers, mirroring +// sanitizeErrorMessage() in main.js (that helper is not importable from here). +function sanitizeMessage(message) { + if (typeof message !== 'string') return String(message); + return message + .replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1') + .replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1'); +} // Must match the MAX_FILE_SIZE convention defined in main.js (50MB). main.js is the // single source of truth for this limit; this module does not redefine it independently @@ -40,6 +69,7 @@ function validateInput(data) { async function imageConvert(data) { try { + const sharp = loadSharp(); validateInput(data); const { inputPath, outputPath, format } = data; @@ -57,6 +87,7 @@ async function imageConvert(data) { async function imageResize(data) { try { + const sharp = loadSharp(); validateInput(data); const { inputPath, outputPath, width = null, height = null, fit = 'inside' } = data; @@ -78,6 +109,7 @@ async function imageResize(data) { async function imageCompress(data) { try { + const sharp = loadSharp(); validateInput(data); const { inputPath, outputPath, quality = 80 } = data; @@ -116,6 +148,7 @@ async function imageCompress(data) { async function imageRotate(data) { try { + const sharp = loadSharp(); validateInput(data); const { inputPath, outputPath, angle } = data; @@ -132,6 +165,16 @@ async function imageRotate(data) { } function executeOperation(operation, data) { + try { + loadSharp(); + } catch (error) { + // Honest degradation: the app keeps booting and every image op reports the + // unavailable state as a resolved result instead of throwing at import time. + return Promise.resolve({ + success: false, + error: `Image operations unavailable: ${sanitizeMessage(error.message)}`, + }); + } switch (operation) { case 'convert': return imageConvert(data); diff --git a/tests/main/ImageOperations.test.js b/tests/main/ImageOperations.test.js index 23233ba..635b758 100644 --- a/tests/main/ImageOperations.test.js +++ b/tests/main/ImageOperations.test.js @@ -68,3 +68,57 @@ describe('ImageOperations', () => { await expect(ImageOperations.executeOperation('bogus', {})).rejects.toThrow(); }); }); + +describe('ImageOperations when sharp fails to load (boot resilience)', () => { + // Reproduces the packaged-deb crash: the native @img/sharp-* bindings are + // missing/pruned, so require('sharp') throws. Importing ImageOperations must + // never crash the app at boot, and every operation must degrade honestly. + const dlopenErrorMessage = + 'Could not load the "sharp" module using the linux-x64 runtime. ' + + 'ERR_DLOPEN_FAILED: libvips-cpp.so.8.17.3: cannot open shared object file ' + + '(searched /opt/MarkdownConverter/resources/app.asar.unpacked/node_modules/@img/' + + 'sharp-linux-x64/lib, /opt/MarkdownConverter/resources/app.asar/node_modules/@img/' + + 'sharp-linux-x64/lib, ...)'; + + let isolatedModule; + + beforeEach(() => { + jest.resetModules(); + jest.doMock('sharp', () => { + throw new Error(dlopenErrorMessage); + }); + jest.isolateModules(() => { + isolatedModule = require('../../src/main/ImageOperations'); + }); + }); + + afterEach(() => { + jest.dontMock('sharp'); + }); + + test('requiring ImageOperations does not throw at import time', () => { + expect(() => require('../../src/main/ImageOperations')).not.toThrow(); + }); + + test("executeOperation('convert') resolves to an honest unavailable failure", async () => { + const result = await isolatedModule.executeOperation('convert', { + inputPath: '/tmp/imgops-resilience-in.png', + outputPath: '/tmp/imgops-resilience-out.jpg', + format: 'jpeg', + }); + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Image operations unavailable'), + }); + }); + + test('the unavailable failure message carries no absolute paths', async () => { + const result = await isolatedModule.executeOperation('rotate', { + inputPath: '/tmp/imgops-resilience-in.png', + outputPath: '/tmp/imgops-resilience-out.png', + angle: 90, + }); + expect(result.success).toBe(false); + expect(result.error).not.toMatch(/\/opt\/|\/tmp\/|[A-Z]:\\/); + }); +});