diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15ba5e1..ce7ce42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,13 @@ jobs: - name: Download external tools (pandoc) run: node scripts/download-tools.js + - name: Bundle MarkItDown (optional, best effort) + # Freezes markitdown + Python into bin/linux/markitdown; failure is + # non-fatal — the build then ships without it and the app falls back + # to a system markitdown at runtime. + run: npm run bundle:markitdown + continue-on-error: true + - name: Run tests run: npm test @@ -59,6 +66,12 @@ jobs: - name: Download external tools (pandoc) run: node scripts/download-tools.js + - name: Bundle MarkItDown (optional, best effort) + # Windows runners ship python + venv; non-fatal on failure — the + # package then omits the bundled binary by design + run: npm run bundle:markitdown + continue-on-error: true + - name: Run tests run: npm test @@ -94,8 +107,44 @@ jobs: dist/*.zip retention-days: 5 + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Download external tools (pandoc) + run: node scripts/download-tools.js + + - name: Bundle MarkItDown (optional, best effort) + run: npm run bundle:markitdown + continue-on-error: true + + - name: Run tests + run: npm test + + - name: Build macOS packages + run: npm run build:mac -- --publish=never + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: | + dist/*.dmg + dist/*.zip + retention-days: 5 + release: - needs: [build-linux, build-windows] + needs: [build-linux, build-windows, build-macos] if: always() runs-on: ubuntu-latest steps: @@ -115,6 +164,13 @@ jobs: name: windows-artifacts path: dist + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: macos-artifacts + path: dist + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/electron-builder.config.js b/electron-builder.config.js new file mode 100644 index 0000000..7797753 --- /dev/null +++ b/electron-builder.config.js @@ -0,0 +1,124 @@ +/** + * electron-builder configuration (replaces the static package.json "build" + * section so it can be computed dynamically). + * + * Why a JS config: the bundled markitdown binary is OPTIONAL at build time — + * PyInstaller can only build for the host OS, so e.g. a Windows package + * built on a machine without bin/win32/markitdown.exe must simply omit it + * (the app falls back to system-installed markitdown at runtime) instead of + * failing on a missing extraFiles source. + * + * Everything else mirrors the previous static config verbatim. + */ +const fs = require('fs'); +const path = require('path'); + +/** Common extraFiles for a platform: pandoc always, markitdown when built. */ +function toolExtraFiles(platform) { + const exe = platform === 'win32' ? '.exe' : ''; + const files = [{ from: `bin/${platform}/pandoc${exe}`, to: `bin/pandoc${exe}` }]; + const markitdown = `bin/${platform}/markitdown${exe}`; + if (fs.existsSync(path.join(__dirname, markitdown))) { + files.push({ from: markitdown, to: `bin/markitdown${exe}` }); + } else { + console.warn( + `[electron-builder.config] ${markitdown} not found — building WITHOUT the ` + + 'bundled markitdown (the app will use a system install if present). ' + + 'Run "npm run bundle:markitdown" on this platform to bundle it.' + ); + } + return files; +} + +module.exports = { + appId: 'com.concreteinfo.markdownconverter', + productName: 'MarkdownConverter', + copyright: 'Copyright (C) 2024-2025 ConcreteInfo', + directories: { output: 'dist' }, + icon: 'assets/icon', + files: [ + 'src/**/*', + 'assets/**/*', + 'scripts/**/*', + 'node_modules/**/*', + 'package.json', + 'THIRD-PARTY-NOTICES.md', + 'SOURCES.md', + 'third-party-licenses/**/*', + ], + asarUnpack: [ + 'node_modules/ffmpeg-static/**', + 'node_modules/sharp/**', + 'node_modules/@img/**', + 'node_modules/@napi-rs/**', + 'assets/fonts/**', + ], + fileAssociations: [ + { + ext: 'md', + name: 'Markdown Document', + description: 'Markdown Document', + mimeType: 'text/markdown', + role: 'Editor', + }, + { + ext: 'markdown', + name: 'Markdown Document', + description: 'Markdown Document', + mimeType: 'text/markdown', + role: 'Editor', + }, + { + ext: 'pdf', + name: 'PDF Document', + description: 'PDF Document', + mimeType: 'application/pdf', + role: 'Editor', + }, + ], + mac: { + category: 'public.app-category.productivity', + identity: null, + extraFiles: toolExtraFiles('darwin'), + }, + win: { + target: [ + { target: 'nsis', arch: ['x64'] }, + { target: 'portable', arch: ['x64'] }, + { target: 'zip', arch: ['x64'] }, + ], + artifactName: '${productName}-${version}-${arch}.${ext}', + requestedExecutionLevel: 'asInvoker', + legalTrademarks: 'Copyright (C) 2024-2025 ConcreteInfo', + verifyUpdateCodeSignature: false, + signAndEditExecutable: false, + extraFiles: toolExtraFiles('win32'), + }, + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + displayLanguageSelector: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: 'MarkdownConverter', + runAfterFinish: true, + menuCategory: 'Productivity', + license: 'LICENSE', + warningsAsErrors: false, + artifactName: '${productName}-Setup-${version}.${ext}', + deleteAppDataOnUninstall: false, + differentialPackage: true, + }, + linux: { + target: ['deb', 'AppImage', 'snap'], + category: 'Utility', + maintainer: 'ConcreteInfo ', + extraFiles: toolExtraFiles('linux'), + }, + deb: { + depends: ['imagemagick', 'libreoffice-common'], + description: 'Professional Markdown editor and universal file converter', + maintainer: 'ConcreteInfo ', + }, +}; diff --git a/package.json b/package.json index 470f607..8a36a4e 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,17 @@ "lint:fix": "eslint src tests --fix", "format": "prettier --write src tests", "format:check": "prettier --check src tests", - "build": "electron-builder", - "build:win": "electron-builder --win", - "build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win", - "build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win", + "build": "electron-builder --config electron-builder.config.js", + "build:win": "electron-builder --config electron-builder.config.js --win", + "build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --config electron-builder.config.js --win", + "build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.config.js --win", "create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1", - "build:mac": "electron-builder --mac", - "build:linux": "electron-builder --linux", - "build:linux-ci": "electron-builder --linux deb AppImage", - "build:local": "electron-builder --linux --win", - "dist": "electron-builder --publish=never", - "dist:all": "electron-builder -mwl", + "build:mac": "electron-builder --config electron-builder.config.js --mac", + "build:linux": "electron-builder --config electron-builder.config.js --linux", + "build:linux-ci": "electron-builder --config electron-builder.config.js --linux deb AppImage", + "build:local": "electron-builder --config electron-builder.config.js --linux --win", + "dist": "electron-builder --config electron-builder.config.js --publish=never", + "dist:all": "electron-builder --config electron-builder.config.js -mwl", "download-tools": "node scripts/download-tools.js", "bundle:markitdown": "node scripts/bundle-markitdown.js", "generate-icons": "node scripts/generate-icons.js" @@ -99,148 +99,5 @@ "lodash.pick": "npm:lodash@^4.17.21", "lodash-es": "^4.18.1", "lodash": "^4.17.21" - }, - "build": { - "appId": "com.concreteinfo.markdownconverter", - "productName": "MarkdownConverter", - "copyright": "Copyright (C) 2024-2025 ConcreteInfo", - "directories": { - "output": "dist" - }, - "icon": "assets/icon", - "files": [ - "src/**/*", - "assets/**/*", - "scripts/**/*", - "node_modules/**/*", - "package.json", - "THIRD-PARTY-NOTICES.md", - "SOURCES.md" - ], - "asarUnpack": [ - "node_modules/ffmpeg-static/**", - "node_modules/sharp/**", - "node_modules/@img/**", - "node_modules/@napi-rs/**", - "assets/fonts/**" - ], - "extraFiles": [], - "fileAssociations": [ - { - "ext": "md", - "name": "Markdown Document", - "description": "Markdown Document", - "mimeType": "text/markdown", - "role": "Editor" - }, - { - "ext": "markdown", - "name": "Markdown Document", - "description": "Markdown Document", - "mimeType": "text/markdown", - "role": "Editor" - }, - { - "ext": "pdf", - "name": "PDF Document", - "description": "PDF Document", - "mimeType": "application/pdf", - "role": "Editor" - } - ], - "mac": { - "category": "public.app-category.productivity", - "identity": null, - "extraFiles": [ - { - "from": "bin/darwin/pandoc", - "to": "bin/pandoc" - }, - { - "from": "bin/darwin/markitdown", - "to": "bin/markitdown" - } - ] - }, - "win": { - "target": [ - { - "target": "nsis", - "arch": [ - "x64" - ] - }, - { - "target": "portable", - "arch": [ - "x64" - ] - }, - { - "target": "zip", - "arch": [ - "x64" - ] - } - ], - "artifactName": "${productName}-${version}-${arch}.${ext}", - "requestedExecutionLevel": "asInvoker", - "legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo", - "verifyUpdateCodeSignature": false, - "signAndEditExecutable": false, - "extraFiles": [ - { - "from": "bin/win32/pandoc.exe", - "to": "bin/pandoc.exe" - }, - { - "from": "bin/win32/markitdown.exe", - "to": "bin/markitdown.exe" - } - ] - }, - "nsis": { - "oneClick": false, - "perMachine": false, - "allowToChangeInstallationDirectory": true, - "displayLanguageSelector": true, - "createDesktopShortcut": true, - "createStartMenuShortcut": true, - "shortcutName": "MarkdownConverter", - "runAfterFinish": true, - "menuCategory": "Productivity", - "license": "LICENSE", - "warningsAsErrors": false, - "artifactName": "${productName}-Setup-${version}.${ext}", - "deleteAppDataOnUninstall": false, - "differentialPackage": true - }, - "linux": { - "target": [ - "deb", - "AppImage", - "snap" - ], - "category": "Utility", - "maintainer": "ConcreteInfo ", - "extraFiles": [ - { - "from": "bin/linux/pandoc", - "to": "bin/pandoc" - }, - { - "from": "bin/linux/markitdown", - "to": "bin/markitdown" - } - ] - }, - "deb": { - "depends": [ - "imagemagick", - "libreoffice-common" - ], - "description": "Professional Markdown editor and universal file converter", - "maintainer": "ConcreteInfo " - } } } diff --git a/scripts/download-tools.js b/scripts/download-tools.js index 706faab..e73740a 100644 --- a/scripts/download-tools.js +++ b/scripts/download-tools.js @@ -27,9 +27,9 @@ const PANDOC_VERSION = '3.9.0.2'; */ const KNOWN_SHA256 = { 'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597', + 'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba', // Fill these from a trusted machine after the first download of each // platform (the script prints the computed hash): - // 'win32:pandoc.exe': '…', // 'darwin:pandoc': '…', 'fonts:FiraCode-Regular.ttf': '3c79d234a9161c790410ebb2a80de7efb7c15f581062c130e0fa78503ccdd0da', 'fonts:FiraCode-Bold.ttf': '975f26779fac1029c2cbdac1e9fac7e9ddeec05e064675e4aac63bffa121742f', diff --git a/src/main.js b/src/main.js index 4d8e530..3434a7c 100644 --- a/src/main.js +++ b/src/main.js @@ -27,11 +27,27 @@ if (process.platform === 'win32') { } } +/** + * Directory of the bundled external tools (pandoc/markitdown) in a PACKAGED + * app. electron-builder extraFiles land next to the executable on Linux and + * Windows, and inside Contents/ on macOS — NOT in resources/bin (which is + * where the lookup incorrectly pointed before v4.7, making packaged builds + * silently fall back to system-installed tools). + */ +function bundledToolDir() { + if (process.platform === 'darwin') { + // resourcesPath = …/Contents/Resources → tools live in …/Contents/bin + return path.join(path.dirname(process.resourcesPath), 'bin'); + } + // exePath = …/MarkdownConverter(.exe) → tools live in …/bin + return path.join(path.dirname(process.execPath), 'bin'); +} + // Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise. function getPandocPath() { if (app.isPackaged) { const ext = process.platform === 'win32' ? '.exe' : ''; - return path.join(process.resourcesPath, 'bin', `pandoc${ext}`); + return path.join(bundledToolDir(), `pandoc${ext}`); } // Development: prefer locally-downloaded binary in bin// const devBin = path.join( diff --git a/src/main/MarkItDown.js b/src/main/MarkItDown.js index 7241a35..c6a5e88 100644 --- a/src/main/MarkItDown.js +++ b/src/main/MarkItDown.js @@ -24,25 +24,32 @@ const MAX_OUTPUT_BUFFER = 20 * 1024 * 1024; /** * Path to the bundled markitdown binary (built by scripts/bundle-markitdown.js - * via PyInstaller), when the app ships one. Mirrors getPandocPath's layout: - * dev: bin//markitdown · packaged: /bin/markitdown. - * Returns null when no bundle exists (PATH/python fallbacks apply). + * via PyInstaller), when the app ships one. Layout matches getPandocPath: + * dev: bin//markitdown · packaged: /bin/markitdown (next + * to the executable on Linux/Windows, Contents/bin on macOS — the same + * extraFiles destination). Returns null when absent (PATH/python fallbacks). */ function getBundledMarkItDownPath() { const pathUtil = require('path'); const fs = require('fs'); const exe = process.platform === 'win32' ? 'markitdown.exe' : 'markitdown'; - if (typeof process === 'object' && process.resourcesPath && !process.resourcesPath.includes('node_modules')) { - // Packaged (Electron) — resourcesPath only exists in a real app runtime - try { - const electron = require('electron'); - if (electron.app?.isPackaged) { - const packaged = pathUtil.join(process.resourcesPath, 'bin', exe); - if (fs.existsSync(packaged)) return packaged; - } - } catch { - /* not running under Electron (tests) — fall through to dev layout */ + try { + // Only meaningful under Electron; tests import this module under Node + const electron = require('electron'); + if (electron.app?.isPackaged) { + const base = + process.platform === 'darwin' + ? pathUtil.dirname(electron.app.getPath('exe')) // …/Contents/MacOS + : pathUtil.dirname(process.execPath); + const packaged = + process.platform === 'darwin' + ? pathUtil.join(base, '..', 'bin', exe) // …/Contents/bin + : pathUtil.join(base, 'bin', exe); + if (fs.existsSync(packaged)) return packaged; + return null; } + } catch { + /* not running under Electron (tests) — use the dev layout below */ } const dev = pathUtil.join(__dirname, '..', '..', 'bin', process.platform, exe); return fs.existsSync(dev) ? dev : null; diff --git a/tests/bundled-tools.test.js b/tests/bundled-tools.test.js index 69df850..8c1b246 100644 --- a/tests/bundled-tools.test.js +++ b/tests/bundled-tools.test.js @@ -87,9 +87,18 @@ describe('Legal compliance artifacts', () => { expect(sources).toMatch(/https:\/\/ffmpeg\.org\/releases\//); }); - test('packaging includes the legal documents in build.files', () => { + test('packaging includes the legal documents in build files', () => { + // The build config lives in electron-builder.config.js (dynamic — it + // conditionally bundles the markitdown binary per platform) + const configSrc = fs.readFileSync( + path.join(rootDir, 'electron-builder.config.js'), + 'utf-8' + ); + expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'"); + expect(configSrc).toContain("'SOURCES.md'"); + expect(configSrc).toContain('third-party-licenses'); + // And package.json must not carry a stale static build section anymore const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8')); - expect(pkg.build.files).toContain('THIRD-PARTY-NOTICES.md'); - expect(pkg.build.files).toContain('SOURCES.md'); + expect(pkg.build).toBeUndefined(); }); }); diff --git a/tests/packaging-sharp.test.js b/tests/packaging-sharp.test.js index 7090495..5dd11cc 100644 --- a/tests/packaging-sharp.test.js +++ b/tests/packaging-sharp.test.js @@ -50,8 +50,11 @@ function listFilesRecursively(dir) { ); test('sharp-linux-x64 ships its native binding', () => { - const binding = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib', 'sharp-linux-x64.node'); - expect(fs.existsSync(binding)).toBe(true); + // sharp 0.35 names the binding with a version suffix + // (sharp-linux-x64-0.35.4.node); 0.34 used a bare name — accept both + const libDir = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib'); + const bindings = fs.readdirSync(libDir).filter((f) => /^sharp-linux-x64.*\.node$/.test(f)); + expect(bindings.length).toBeGreaterThan(0); }); test('sharp-libvips-linux-x64 ships the bundled libvips shared libraries', () => {