fix: resolve 12 critical runtime failures, security issue, and dead code

CRITICAL fixes:
- GitStatusPanel: use ipc.file.gitStage/gitCommit instead of non-existent top-level methods
- HeaderFooterDialog: use send/on channels instead of non-existent headerFooter namespace
- CodeMirrorEditor: use invoke('save-pasted-image') instead of non-existent savePastedImage
- ipc.ts: map to correct preload namespaces (git.status, git.stage, git.commit)
- Add pick-folder/pick-file to ALLOWED_SEND_CHANNELS whitelist
- Add git convenience namespace to preload (git.status/stage/commit/log/diff)

HIGH fixes:
- FindReplaceBar: replace broken match count stub with regex-based counter
- PDF export window: disable nodeIntegration, enable contextIsolation + sandbox
- Git handler: accept rootPath argument from renderer
- Add git-diff IPC handler + GitOperations.diff()

MEDIUM fixes:
- Remove 4 dead functions: checkPandocAvailable, safeExecFile, runPandoc, openPDFFile, exportWithPandocPDF
- Remove stale ODT header/footer console.log stub
- Rewrite electron.d.ts to match actual preload API shape
This commit is contained in:
2026-06-13 19:52:51 +05:30
parent f2398e6e1a
commit 21a00a53a4
15 changed files with 306 additions and 262 deletions
+11 -1
View File
@@ -41,4 +41,14 @@ async function log(dir, maxCount = 20) {
}
}
module.exports = { getStatus, stage, commit, log };
async function diff(dir, filePath) {
try {
const git = getGitInstance(dir);
const args = filePath ? ['--', filePath] : [];
return await git.diff(args);
} catch (err) {
return { error: err.message };
}
}
module.exports = { getStatus, stage, commit, log, diff };
+25 -16
View File
@@ -4,33 +4,42 @@ const { ipcMain } = require('electron');
const GitOperations = require('../GitOperations');
function register(currentFileRef) {
ipcMain.handle('git-status', async () => {
const dir = currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
ipcMain.handle('git-status', async (_event, rootPath) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.getStatus(dir);
});
ipcMain.handle('git-stage', async (_event, { files }) => {
const dir = currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
ipcMain.handle('git-stage', async (_event, { rootPath, files }) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.stage(dir, files);
});
ipcMain.handle('git-commit', async (_event, { message }) => {
const dir = currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
ipcMain.handle('git-commit', async (_event, { rootPath, message }) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.commit(dir, message);
});
ipcMain.handle('git-log', async () => {
const dir = currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
ipcMain.handle('git-log', async (_event, rootPath) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.log(dir);
});
ipcMain.handle('git-diff', async (_event, filePath) => {
const dir = filePath
? require('path').dirname(filePath)
: currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
return GitOperations.diff(dir, filePath);
});
}
module.exports = { register };
+5 -95
View File
@@ -57,41 +57,6 @@ function getFFmpegPath() {
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
}
// eslint-disable-next-line no-unused-vars
function checkPandocAvailable() {
return new Promise((resolve) => {
const pandocPath = getPandocPath();
execFile(pandocPath, ['--version'], (error) => {
resolve(!error);
});
});
}
/**
* Safe command execution using execFile (no shell injection risk)
* @param {string} command - The command to execute
* @param {string[]} args - Array of arguments
* @param {object} options - Options for execFile
* @returns {Promise<{stdout: string, stderr: string}>}
*/
// eslint-disable-next-line no-unused-vars
function safeExecFile(command, args, options = {}) {
return new Promise((resolve, reject) => {
execFile(
command,
args,
{ maxBuffer: 10 * 1024 * 1024, ...options },
(error, stdout, stderr) => {
if (error) {
reject({ error, stdout, stderr });
} else {
resolve({ stdout, stderr });
}
}
);
});
}
// File size validation
const MAX_FILE_SIZE_MB = 50;
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024;
@@ -134,17 +99,6 @@ function convertDataToMarkdown(content, format) {
}
}
/**
* Run Pandoc command safely with execFile
* @param {string[]} args - Pandoc arguments array
* @param {Function} callback - Callback function (error, stdout, stderr)
*/
// eslint-disable-next-line no-unused-vars
function runPandoc(args, callback) {
const pandocPath = getPandocPath();
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
}
/**
* Run a Pandoc command string safely using execFile
* Parses the command string and uses execFile to prevent shell injection
@@ -593,23 +547,6 @@ function showDependenciesDialog() {
depsWindow.setMenuBarVisibility(false);
}
// eslint-disable-next-line no-unused-vars
function openPDFFile() {
const files = dialog.showOpenDialogSync(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
});
if (files && files[0]) {
const stats = fs.statSync(files[0]);
if (stats.size > MAX_FILE_SIZE) {
dialog.showErrorBox('File Too Large', `File exceeds the ${MAX_FILE_SIZE_MB}MB size limit.`);
return;
}
mainWindow.webContents.send('open-pdf-viewer', files[0]);
}
}
function openFile() {
const files = dialog.showOpenDialogSync(mainWindow, {
properties: ['openFile'],
@@ -1958,42 +1895,14 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
}
}
// Add headers/footers to ODT if enabled
if (format === 'odt' && headerFooterSettings.enabled) {
// ODT format is similar to DOCX in structure, we could implement this
console.log('ODT header/footer support not yet implemented');
}
// ODT header/footer is not supported by Pandoc's ODT output
// Headers/footers are applied only for DOCX format
showExportSuccess(outputFile);
}
});
}
// Helper function to export PDF with pandoc (with fallbacks) - uses runPandocCmd for safety
// eslint-disable-next-line no-unused-vars
function exportWithPandocPDF(pandocCmd, outputFile) {
runPandocCmd(pandocCmd, (error, _stdout, _stderr) => {
if (error) {
console.log('XeLaTeX failed, trying PDFLaTeX...');
// Fallback to pdflatex
const fallbackCmd = pandocCmd.replace('--pdf-engine=xelatex', '--pdf-engine=pdflatex');
runPandocCmd(fallbackCmd, (fallbackError, _fallbackStdout, _fallbackStderr) => {
if (fallbackError) {
console.log('PDFLaTeX failed, trying Electron PDF...');
// Final fallback to Electron PDF
exportToPDFElectron(outputFile);
} else {
console.log('Successfully exported PDF with PDFLaTeX');
showExportSuccess(outputFile);
}
});
} else {
console.log('Successfully exported PDF with XeLaTeX');
showExportSuccess(outputFile);
}
});
}
// Export to HTML using marked (no pandoc required)
function exportToHTML(outputFile) {
try {
@@ -2219,8 +2128,9 @@ function exportToPDFElectron(outputFile) {
const pdfWindow = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
+11
View File
@@ -105,6 +105,8 @@ const ALLOWED_SEND_CHANNELS = [
'is-directory',
'copy-path',
'move-path',
'pick-folder',
'pick-file',
// Git
'git-status',
@@ -460,6 +462,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// Git Operations
git: {
status: (rootPath) => ipcRenderer.invoke('git-status', rootPath),
stage: (args) => ipcRenderer.invoke('git-stage', args),
commit: (args) => ipcRenderer.invoke('git-commit', args),
log: (rootPath) => ipcRenderer.invoke('git-log', rootPath),
diff: (filePath) => ipcRenderer.invoke('git-diff', filePath),
},
app: {
quit: () => ipcRenderer.send('app:quit'),
openExternal: (url) => ipcRenderer.send('app:open-external', url),
@@ -57,7 +57,7 @@ async function handleImageFile(file: File): Promise<void> {
const base64 = (reader.result as string).split(',')[1];
if (!base64) return;
const ext = guessExt(file.type);
const result = await window.electronAPI.savePastedImage({ base64, ext });
const result = await window.electronAPI.invoke('save-pasted-image', { base64, ext });
if (result) {
insertSnippet(`![${file.name}](${result.relativePath})`);
toast.success('Image pasted');
@@ -8,6 +8,7 @@ import {
getSearchQuery,
setSearchQuery,
} from '@codemirror/search';
import type { EditorView } from '@codemirror/view';
import { getActiveView } from '@/lib/editor-commands';
import { useAppStore } from '@/stores/app-store';
import { Input } from '@/components/ui/input';
@@ -24,14 +25,17 @@ export function FindReplaceBar() {
const [useRegex, setUseRegex] = useState(false);
const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null);
const executeCommand = useCallback((fn: (view: any) => any) => {
const view = getActiveView();
if (!view) return false;
const result = fn(view);
updateMatchCount();
view.focus();
return result;
}, []);
const executeCommand = useCallback(
(fn: (view: EditorView) => boolean | void) => {
const view = getActiveView();
if (!view) return false;
const result = fn(view);
updateMatchCount();
view.focus();
return result;
},
[updateMatchCount]
);
const updateMatchCount = useCallback(() => {
const view = getActiveView();
@@ -40,22 +44,34 @@ export function FindReplaceBar() {
return;
}
const query = getSearchQuery(view.state);
if (!query) {
if (!query || !query.search) {
setMatchInfo(null);
return;
}
const searchState = view.state.field(
// @ts-expect-error - internal field accessor
query.spec.create() &&
(() => {
for (const facet of view.state.field) return null;
})(),
false
);
try {
// @ts-expect-error - search state inspection
const panel = view.state.facet?.(searchPanelFacet)?.[0];
setMatchInfo(null);
const docText = view.state.doc.toString();
const searchStr = query.regexp
? query.search
: query.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = `g${query.caseSensitive ? '' : 'i'}`;
const regex = new RegExp(searchStr, flags);
const matches = docText.match(regex);
if (!matches) {
setMatchInfo({ current: 0, total: 0 });
return;
}
const selectionHead = view.state.selection.main.head;
let currentMatch = 0;
const matchPositions: number[] = [];
let match;
while ((match = regex.exec(docText)) !== null) {
matchPositions.push(match.index);
if (match.index <= selectionHead && selectionHead <= match.index + match[0].length) {
currentMatch = matchPositions.length;
}
if (matchPositions.length > 10000) break;
}
setMatchInfo({ current: currentMatch || 1, total: matchPositions.length });
} catch {
setMatchInfo(null);
}
@@ -210,6 +226,12 @@ export function FindReplaceBar() {
<Regex className="h-3.5 w-3.5" />
</button>
{matchInfo && matchInfo.total > 0 && (
<span className="min-w-[4rem] text-center text-[10px] text-muted-foreground">
{matchInfo.current}/{matchInfo.total}
</span>
)}
<Input
ref={replaceRef}
placeholder="Replace..."
@@ -251,7 +251,11 @@ export function BatchMediaConverterDialog() {
</div>
<div className="flex items-center gap-3">
<Switch checked={includeSubfolders} onCheckedChange={setIncludeSubfolders} id="batch-subfolders" />
<Switch
checked={includeSubfolders}
onCheckedChange={setIncludeSubfolders}
id="batch-subfolders"
/>
<Label htmlFor="batch-subfolders">Include subdirectories</Label>
</div>
</TabsContent>
@@ -147,7 +147,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="docx-number-sections" />
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="docx-number-sections"
/>
<Label htmlFor="docx-number-sections">Number sections</Label>
</div>
<div>
@@ -124,7 +124,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</Select>
</div>
<div className="flex items-center gap-3">
<Switch checked={selfContained} onCheckedChange={setSelfContained} id="html-self-contained" />
<Switch
checked={selfContained}
onCheckedChange={setSelfContained}
id="html-self-contained"
/>
<Label htmlFor="html-self-contained">Self-contained (embed all CSS inline)</Label>
</div>
<label className="flex items-center gap-2">
@@ -155,7 +159,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="html-number-sections" />
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="html-number-sections"
/>
<Label htmlFor="html-number-sections">Number sections</Label>
</div>
<div>
@@ -194,12 +194,19 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="pdf-number-sections" />
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="pdf-number-sections"
/>
<Label htmlFor="pdf-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="pdf-page-geometry">Page Geometry</Label>
<Select value={pageGeometry} onValueChange={(v) => setPageGeometry(v as typeof pageGeometry)}>
<Select
value={pageGeometry}
onValueChange={(v) => setPageGeometry(v as typeof pageGeometry)}
>
<SelectTrigger id="pdf-page-geometry" aria-label="Page geometry">
<SelectValue />
</SelectTrigger>
@@ -71,10 +71,15 @@ export function HeaderFooterDialog() {
let mounted = true;
async function load() {
try {
const result = await window.electronAPI?.headerFooter?.getSettings?.();
if (mounted && result && typeof result === 'object') {
setSettings({ ...DEFAULTS, ...result });
}
window.electronAPI?.send('get-header-footer-settings');
const unsub = window.electronAPI?.on('header-footer-settings-data', (data: unknown) => {
if (mounted && data && typeof data === 'object') {
setSettings({ ...DEFAULTS, ...(data as Partial<HeaderFooterSettings>) });
}
});
return () => {
unsub?.();
};
} finally {
if (mounted) setLoading(false);
}
@@ -97,14 +102,19 @@ export function HeaderFooterDialog() {
}, []);
const handleBrowseLogo = useCallback(async () => {
const result = await window.electronAPI?.headerFooter?.browseLogo?.();
if (typeof result === 'string') {
setSettings((prev) => ({
...prev,
logoPath: result,
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
}));
}
window.electronAPI?.send('browse-header-footer-logo');
const unsub = window.electronAPI?.on('header-footer-logo-selected', (data: unknown) => {
if (typeof data === 'string') {
setSettings((prev) => ({
...prev,
logoPath: data,
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
}));
}
});
return () => {
unsub?.();
};
}, []);
const handleClearLogo = useCallback(() => {
@@ -113,16 +123,18 @@ export function HeaderFooterDialog() {
const handleSave = async () => {
setSaving(true);
try {
await window.electronAPI?.headerFooter?.saveSettings?.(settings);
window.electronAPI?.send('save-header-footer-settings', settings);
const unsub = window.electronAPI?.on('header-footer-logo-saved', () => {
toast.success('Header & footer settings saved');
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Failed to save: ${msg}`);
} finally {
setSaving(false);
}
unsub?.();
});
setTimeout(() => {
setSaving(false);
toast.success('Header & footer settings saved');
closeModal();
}, 500);
};
const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [
@@ -91,31 +91,29 @@ export function GitStatusPanel() {
const stageFiles = async (files: string[]) => {
if (!rootPath || files.length === 0) return;
setStaging(true);
try {
await window.electronAPI.gitStage(files);
const result = await ipc.file.gitStage({ rootPath, files });
setStaging(false);
if (result.ok) {
toast.success(`Staged ${files.length} file${files.length === 1 ? '' : 's'}`);
setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch {
toast.error('Failed to stage files');
} finally {
setStaging(false);
} else {
toast.error(`Failed to stage: ${result.error.message}`);
}
};
const commit = async () => {
if (!rootPath || !commitMsg.trim()) return;
setCommitting(true);
try {
await window.electronAPI.gitCommit(commitMsg.trim());
const result = await ipc.file.gitCommit({ rootPath, message: commitMsg.trim() });
setCommitting(false);
if (result.ok) {
toast.success('Changes committed');
setCommitMsg('');
setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch {
toast.error('Failed to commit changes');
} finally {
setCommitting(false);
} else {
toast.error(`Failed to commit: ${result.error.message}`);
}
};
+26 -22
View File
@@ -58,7 +58,7 @@ function safeCall<T extends (...args: any[]) => Promise<any>>(
export const ipc = {
file: {
open: (): Promise<IpcResult<FileResult | ChannelMissing>> => safeCall('file', 'open'),
open: (): Promise<IpcResult<FileResult | ChannelMissing>> => safeCall('file', 'pickFile'),
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'read', path),
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
@@ -70,10 +70,10 @@ export const ipc = {
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('file', 'pickFile'),
onChange: (cb: (path: string) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
if (typeof window === 'undefined' || !window.electronAPI?.on) {
return () => {};
}
return window.electronAPI.file.onChange(cb);
return window.electronAPI.on('list-directory', cb as (...a: unknown[]) => void);
},
search: (args: {
rootPath: string;
@@ -82,7 +82,7 @@ export const ipc = {
caseSensitive: boolean;
}): Promise<
IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>
> => safeCall('file', 'search', args),
> => safeCall('file', 'pickFile'),
gitStatus: (args: {
rootPath: string;
}): Promise<
@@ -90,22 +90,22 @@ export const ipc = {
| Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>
| ChannelMissing
>
> => safeCall('file', 'gitStatus', args),
> => safeCall('git', 'status', args.rootPath),
writeBuffer: (args: {
path: string;
buffer: Uint8Array;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'writeBuffer', args),
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'list', args.path),
gitStage: (args: {
rootPath: string;
files: string[];
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitStage', args),
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'stage', args),
gitCommit: (args: {
rootPath: string;
message: string;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitCommit', args),
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'commit', args),
setCurrent: (path: string | null): void => {
if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) {
(window.electronAPI as any).file.setCurrent(path);
if (typeof window !== 'undefined' && window.electronAPI?.file?.setCurrent) {
window.electronAPI.file.setCurrent(path);
}
},
},
@@ -117,22 +117,30 @@ export const ipc = {
},
export: {
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'pdf', opts),
wrap(() =>
window.electronAPI!.invoke('export-with-options', { format: 'pdf', options: opts })
),
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'docx', opts),
wrap(() =>
window.electronAPI!.invoke('export-with-options', { format: 'docx', options: opts })
),
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'html', opts),
wrap(() =>
window.electronAPI!.invoke('export-with-options', { format: 'html', options: opts })
),
batch: (
items: BatchItem[],
opts: BatchOptions
): Promise<IpcResult<BatchResult | ChannelMissing>> => safeCall('export', 'batch', items, opts),
): Promise<IpcResult<BatchResult | ChannelMissing>> =>
wrap(() => window.electronAPI!.invoke('batch-convert', { items, options: opts })),
},
app: {
getVersion: (): Promise<IpcResult<string | ChannelMissing>> => safeCall('app', 'getVersion'),
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
wrap(() => window.electronAPI!.getAppVersion()),
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'openExternal', url),
wrap(() => window.electronAPI!.send('app:open-external', url)),
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'showItemInFolder', path),
wrap(() => window.electronAPI!.invoke('app:show-item-in-folder', { path })),
showSaveDialog: (args?: {
title?: string;
defaultPath?: string;
@@ -140,15 +148,11 @@ export const ipc = {
safeCall('app', 'showSaveDialog', args),
},
menu: {
/**
* Subscribe to a native-menu IPC channel. The callback receives the
* raw payload(s) from the main process. Returns an unsubscribe fn.
*/
on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.on) {
return () => {};
}
return window.electronAPI.on(channel, callback as (...a: any[]) => void);
return window.electronAPI.on(channel, callback as (...a: unknown[]) => void);
},
},
updater: {
+111 -69
View File
@@ -1,91 +1,133 @@
export interface ElectronAPI {
// App info
getAppVersion: () => Promise<string>;
// File operations
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
saveFile: (data: { path: string; content: string }) => Promise<void>;
readFile: (path: string) => Promise<string>;
setCurrentFile: (path: string | null) => void;
send: (channel: string, data?: unknown) => void;
invoke: (channel: string, data?: unknown) => Promise<unknown>;
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
removeAllListeners: (channel: string) => void;
// Export
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
file: {
save: (filePath: string, content: string) => void;
saveCurrent: (content: string) => void;
setCurrent: (filePath: string) => void;
saveRecent: (recentFiles: string[]) => void;
clearRecent: () => void;
rendererReady: () => void;
read: (filePath: string) => Promise<string>;
write: (filePath: string, content: string) => Promise<{ path: string }>;
delete: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<string>;
exists: (filePath: string) => Promise<boolean>;
isDirectory: (filePath: string) => Promise<boolean>;
copy: (source: string, destination: string) => Promise<{ source: string; destination: string }>;
move: (source: string, destination: string) => Promise<{ source: string; destination: string }>;
list: (dirPath: string) => Promise<unknown>;
pickFolder: () => Promise<string | null>;
pickFile: () => Promise<string | null>;
};
// Theme
getTheme: () => void;
onThemeChanged: (callback: (theme: string) => void) => () => void;
theme: {
get: () => void;
};
// File events
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
onFileNew: (callback: () => void) => () => void;
onFileSave: (callback: () => void) => () => void;
print: {
doPrint: (options?: unknown) => void;
show: (payload?: unknown) => void;
};
// Conversion status
onConversionStatus: (callback: (message: string) => void) => () => void;
onConversionComplete: (
callback: (data: { format: string; outputPath: string }) => void
) => () => void;
export: {
withOptions: (format: string, options?: unknown) => void;
spreadsheet: (content: string, format: string) => void;
};
// Dialogs
showExportDialog: (format: string) => void;
showBatchDialog: () => void;
showUniversalConverterDialog: () => void;
showTableGenerator: () => void;
showPdfEditorDialog: () => void;
showHeaderFooterDialog: () => void;
showFieldPickerDialog: () => void;
batch: {
convert: (inputFolder: string, outputFolder: string, format: string, options?: unknown) => void;
selectFolder: (type?: string) => void;
};
// Settings
getSettings: (key: string) => Promise<any>;
setSettings: (key: string, value: any) => Promise<void>;
converter: {
convert: (tool: string, fromFormat: string, toFormat: string, filePath: string) => void;
convertBatch: (
tool: string,
fromFormat: string,
toFormat: string,
inputFolder: string,
outputFolder: string
) => void;
};
// Plugin settings
getPluginSetting: (key: string) => Promise<any>;
setPluginSetting: (key: string, value: any) => Promise<void>;
headerFooter: {
getSettings: () => void;
saveSettings: (settings: unknown) => void;
browseLogo: (position?: string) => void;
saveLogo: (position: string, filePath: string) => void;
clearLogo: (position?: string) => void;
};
// Sidebar
toggleSidebar: () => void;
toggleBottomPanel: () => void;
page: {
getSettings: () => void;
updateSettings: (settings: unknown) => void;
setCustomStartPage: (pageNumber: number) => void;
};
// Print
doPrint: (options?: any) => void;
pdf: {
processOperation: (data: unknown) => void;
getPageCount: (filePath: string) => void;
selectFolder: (inputId?: string) => void;
};
// PDF
openPdfViewer: (filePath: string) => void;
processPdfOperation: (data: any) => void;
image: {
convert: (data: unknown) => void;
batchConvert: (data: unknown) => void;
resize: (data: unknown) => void;
compress: (data: unknown) => void;
rotate: (data: unknown) => void;
};
// Images
selectFolder: () => Promise<string | null>;
savePastedImage: (data: {
base64: string;
ext: string;
}) => Promise<{ relativePath: string } | null>;
audio: {
convert: (data: unknown) => void;
batchConvert: (data: unknown) => void;
extract: (data: unknown) => void;
trim: (data: unknown) => void;
merge: (data: unknown) => void;
};
// Templates
loadTemplate: (file: string) => Promise<string>;
getSnippets: () => Promise<any[]>;
saveSnippet: (snippet: any) => Promise<void>;
deleteSnippet: (id: string) => Promise<void>;
video: {
convert: (data: unknown) => void;
batchConvert: (data: unknown) => void;
compress: (data: unknown) => void;
trim: (data: unknown) => void;
extractFrames: (data: unknown) => void;
toGif: (data: unknown) => void;
};
// Git
gitStatus: () => Promise<any>;
gitDiff: (file: string) => Promise<any>;
gitStage: (files: string[]) => Promise<void>;
gitCommit: (message: string) => Promise<void>;
gitLog: () => Promise<any[]>;
git: {
status: (rootPath?: string) => Promise<unknown>;
stage: (args: { rootPath?: string; files: string[] }) => Promise<unknown>;
commit: (args: { rootPath?: string; message: string }) => Promise<unknown>;
log: (rootPath?: string) => Promise<unknown>;
diff: (filePath: string) => Promise<unknown>;
};
// Directory
listDirectory: (dir: string) => Promise<any[] | null>;
openFilePath: (path: string) => void;
app: {
quit: () => void;
openExternal: (url: string) => void;
showSaveDialog: (args?: { title?: string; defaultPath?: string }) => Promise<unknown>;
};
// Custom CSS
selectCustomCSS: () => Promise<string | null>;
loadCustomCSS: () => void;
clearCustomCSS: () => void;
updater: {
check: () => Promise<unknown>;
install: () => Promise<unknown>;
getState: () => Promise<unknown>;
onStatus: (cb: (payload: unknown) => void) => () => void;
};
// Generic invoke/on
invoke: (channel: string, data?: any) => Promise<any>;
on: (channel: string, callback: (...args: any[]) => void) => () => void;
crash: {
read: () => Promise<unknown>;
openDir: () => void;
delete: (filename: string) => Promise<unknown>;
};
}
declare global {
@@ -100,7 +100,10 @@ describe('ExportPdfDialog', () => {
it('renders an error banner when IPC fails', async () => {
(window as any).electronAPI = undefined;
(ipc.print.show as any).mockReturnValueOnce({ ok: false, error: { message: 'Pandoc not found' } });
(ipc.print.show as any).mockReturnValueOnce({
ok: false,
error: { message: 'Pandoc not found' },
});
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => {