feat(legal): bundle small dependencies, add notices, credits, and GPL source offers

Bundle (v4.7.0):
- MarkItDown frozen to a single ~75MB per-platform binary (PyInstaller via
  npm run bundle:markitdown; ML extras excluded) — built and verified
  locally: HTML/XLSX/PDF conversions pass through the bundled binary, and
  the app resolves bin/linux/markitdown first at runtime
- Packaging copies bundled markitdown alongside Pandoc for win/mac/linux;
  FFmpeg/sharp/KaTeX/fonts were already bundled

Legal artifacts:
- THIRD-PARTY-NOTICES.md: complete license inventory of everything
  distributed (binaries, npm runtime deps, fonts, embedded Python packages)
- SOURCES.md: GPL §3(b) written source offers for Pandoc 3.9.0.2,
  ffmpeg-static's GPL build, and the PyInstaller bootloader; LGPL relinking
  note for libvips
- third-party-licenses/: canonical GPL-2.0, LGPL-2.1, MPL-2.0, Apache-2.0,
  OFL-1.1, PSF-Python texts
- Help > Third-Party Notices & Licenses: in-app viewer for both documents
- README: 'Bundled Dependencies, Legal Notices & Credits' section

Hardening:
- download-tools.js now SHA-256 pins every artifact, verifies after
  download AND against the cache on every run, and hard-fails on mismatch
  (closes security finding D6)

Large tools intentionally not bundled (documented): LibreOffice,
MiKTeX/TeX Live, ImageMagick, PlantUML+JRE, Calibre.

637/637 tests green; lint clean; clean boot; bundled binary verified.
This commit is contained in:
2026-09-05 22:30:54 +05:30
parent 58bd19ecd1
commit 1e24b52f3e
18 changed files with 2415 additions and 19 deletions
+68
View File
@@ -1561,6 +1561,12 @@ function createMenu() {
label: 'Dependencies & Requirements',
click: () => showDependenciesDialog(),
},
{
// Legal: bundled-component licenses + GPL source offer (in-app copy
// of THIRD-PARTY-NOTICES.md and SOURCES.md)
label: 'Third-Party Notices & Licenses',
click: () => showThirdPartyNoticesWindow(),
},
{
type: 'separator',
},
@@ -1685,6 +1691,68 @@ function showAboutDialog() {
}
// Show Dependencies Dialog
/**
* In-app legal window: renders THIRD-PARTY-NOTICES.md and SOURCES.md (both
* ship inside the app — dev: repo root, packaged: asar root via build.files).
* The markdown is shown verbatim in a <pre> with light styling rather than
* rendered, so license texts stay exactly as written.
*/
function showThirdPartyNoticesWindow() {
const noticesWindow = new BrowserWindow({
width: 820,
height: 640,
parent: mainWindow,
title: 'Third-Party Notices & Licenses',
icon: path.join(__dirname, '../assets/icon.png'),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
noticesWindow.setMenuBarVisibility(false);
// Both files are read defensively so a packaging slip degrades gracefully
const readDoc = (file) => {
try {
return fs.readFileSync(path.join(__dirname, '..', file), 'utf-8');
} catch {
return `(Could not read ${file} in this installation — see the source repository.)`;
}
};
const esc = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html =
'<!doctype html><html><head><meta charset="utf-8"><title>Third-Party Notices</title><style>' +
'body{margin:0;font:13px/1.6 system-ui,sans-serif;background:#fafafa;color:#222}' +
'header{position:sticky;top:0;background:#fff;border-bottom:1px solid #e5e7eb;padding:10px 20px;display:flex;gap:16px;align-items:center;z-index:1}' +
'header h1{font-size:15px;margin:0;flex:1}' +
'header button{padding:6px 14px;cursor:pointer;border:1px solid #d1d5db;border-radius:4px;background:#fff}' +
'header button.active{background:#4a90d9;color:#fff;border-color:#4a90d9}' +
'pre{white-space:pre-wrap;word-break:break-word;padding:20px 24px;margin:0;font:12px/1.65 ui-monospace,Menlo,Consolas,monospace}' +
'body.dark pre{background:#1f2937;color:#e5e7eb}body.dark{background:#111}' +
'</style></head><body>' +
'<header><h1>MarkdownConverter — Third-Party Notices</h1>' +
'<button id="tab-notices" class="active">Notices</button>' +
'<button id="tab-sources">Source Offers</button></header>' +
`<pre id="content"></pre>` +
'<script>' +
'const notices=' +
JSON.stringify(esc(readDoc('THIRD-PARTY-NOTICES.md'))) +
';' +
'const sources=' +
JSON.stringify(esc(readDoc('SOURCES.md'))) +
';' +
'const c=document.getElementById("content");' +
'c.textContent=notices;' +
'document.getElementById("tab-notices").onclick=e=>{c.textContent=notices;swap(e)};' +
'document.getElementById("tab-sources").onclick=e=>{c.textContent=sources;swap(e)};' +
'function swap(e){document.querySelectorAll("header button").forEach(b=>b.classList.remove("active"));e.target.classList.add("active")}' +
'</script></body></html>';
noticesWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
}
function showDependenciesDialog() {
const depsWindow = new BrowserWindow({
width: 600,
+43 -10
View File
@@ -23,15 +23,47 @@ const CONVERT_TIMEOUT_MS = 120000;
const MAX_OUTPUT_BUFFER = 20 * 1024 * 1024;
/**
* Candidate command templates probed in order. `argsPrefix` is prepended to
* the user path when invoking (e.g. ['-m', 'markitdown'] for module-style
* invocation through a python launcher).
* 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/<platform>/markitdown · packaged: <resourcesPath>/bin/markitdown.
* Returns null when no bundle exists (PATH/python fallbacks apply).
*/
const COMMAND_CANDIDATES = [
{ command: 'markitdown', argsPrefix: [] },
{ command: process.platform === 'win32' ? 'python' : 'python3', argsPrefix: ['-m', 'markitdown'] },
{ command: 'python3', argsPrefix: ['-m', 'markitdown'] },
];
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 */
}
}
const dev = pathUtil.join(__dirname, '..', '..', 'bin', process.platform, exe);
return fs.existsSync(dev) ? dev : null;
}
/**
* Candidate command templates probed in order (bundled binary first). The
* bundled candidate is verified with the same --version probe as the rest.
*/
function commandCandidates() {
const bundled = getBundledMarkItDownPath();
const candidates = [];
if (bundled) candidates.push({ command: bundled, argsPrefix: [], bundled: true });
candidates.push({ command: 'markitdown', argsPrefix: [] });
candidates.push({
command: process.platform === 'win32' ? 'python' : 'python3',
argsPrefix: ['-m', 'markitdown'],
});
candidates.push({ command: 'python3', argsPrefix: ['-m', 'markitdown'] });
return candidates;
}
/** Run one probe: `--version` exits 0 when the tool is importable. */
function probeCandidate(runner, candidate) {
@@ -61,7 +93,7 @@ function probeCandidate(runner, candidate) {
* @returns {Promise<{command: string, argsPrefix: string[], version: string|null}|null>}
*/
async function resolveMarkItDown(runner) {
for (const candidate of COMMAND_CANDIDATES) {
for (const candidate of commandCandidates()) {
const resolved = await probeCandidate(runner, candidate);
if (resolved) return resolved;
}
@@ -153,5 +185,6 @@ async function convertToMarkdown(inputPath, options = {}) {
module.exports = {
resolveMarkItDown,
convertToMarkdown,
COMMAND_CANDIDATES,
commandCandidates,
getBundledMarkItDownPath,
};