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 };
+24 -15
View File
@@ -4,32 +4,41 @@ const { ipcMain } = require('electron');
const GitOperations = require('../GitOperations'); const GitOperations = require('../GitOperations');
function register(currentFileRef) { function register(currentFileRef) {
ipcMain.handle('git-status', async () => { ipcMain.handle('git-status', async (_event, rootPath) => {
const dir = currentFileRef.current const dir =
? require('path').dirname(currentFileRef.current) rootPath ||
: process.cwd(); (currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.getStatus(dir); return GitOperations.getStatus(dir);
}); });
ipcMain.handle('git-stage', async (_event, { files }) => { ipcMain.handle('git-stage', async (_event, { rootPath, files }) => {
const dir = currentFileRef.current const dir =
? require('path').dirname(currentFileRef.current) rootPath ||
: process.cwd(); (currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.stage(dir, files); return GitOperations.stage(dir, files);
}); });
ipcMain.handle('git-commit', async (_event, { message }) => { ipcMain.handle('git-commit', async (_event, { rootPath, message }) => {
const dir = currentFileRef.current const dir =
? require('path').dirname(currentFileRef.current) rootPath ||
: process.cwd(); (currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.commit(dir, message); return GitOperations.commit(dir, message);
}); });
ipcMain.handle('git-log', async () => { ipcMain.handle('git-log', async (_event, rootPath) => {
const dir = currentFileRef.current 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) ? require('path').dirname(currentFileRef.current)
: process.cwd(); : process.cwd();
return GitOperations.log(dir); return GitOperations.diff(dir, filePath);
}); });
} }
+5 -95
View File
@@ -57,41 +57,6 @@ function getFFmpegPath() {
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg'; 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 // File size validation
const MAX_FILE_SIZE_MB = 50; const MAX_FILE_SIZE_MB = 50;
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024; 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 * Run a Pandoc command string safely using execFile
* Parses the command string and uses execFile to prevent shell injection * Parses the command string and uses execFile to prevent shell injection
@@ -593,23 +547,6 @@ function showDependenciesDialog() {
depsWindow.setMenuBarVisibility(false); 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() { function openFile() {
const files = dialog.showOpenDialogSync(mainWindow, { const files = dialog.showOpenDialogSync(mainWindow, {
properties: ['openFile'], properties: ['openFile'],
@@ -1958,42 +1895,14 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
} }
} }
// Add headers/footers to ODT if enabled // ODT header/footer is not supported by Pandoc's ODT output
if (format === 'odt' && headerFooterSettings.enabled) { // Headers/footers are applied only for DOCX format
// ODT format is similar to DOCX in structure, we could implement this
console.log('ODT header/footer support not yet implemented');
}
showExportSuccess(outputFile); 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) // Export to HTML using marked (no pandoc required)
function exportToHTML(outputFile) { function exportToHTML(outputFile) {
try { try {
@@ -2219,8 +2128,9 @@ function exportToPDFElectron(outputFile) {
const pdfWindow = new BrowserWindow({ const pdfWindow = new BrowserWindow({
show: false, show: false,
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: false,
contextIsolation: false, contextIsolation: true,
sandbox: true,
}, },
}); });
+11
View File
@@ -105,6 +105,8 @@ const ALLOWED_SEND_CHANNELS = [
'is-directory', 'is-directory',
'copy-path', 'copy-path',
'move-path', 'move-path',
'pick-folder',
'pick-file',
// Git // Git
'git-status', 'git-status',
@@ -460,6 +462,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAppVersion: () => ipcRenderer.invoke('get-app-version'), 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: { app: {
quit: () => ipcRenderer.send('app:quit'), quit: () => ipcRenderer.send('app:quit'),
openExternal: (url) => ipcRenderer.send('app:open-external', url), 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]; const base64 = (reader.result as string).split(',')[1];
if (!base64) return; if (!base64) return;
const ext = guessExt(file.type); 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) { if (result) {
insertSnippet(`![${file.name}](${result.relativePath})`); insertSnippet(`![${file.name}](${result.relativePath})`);
toast.success('Image pasted'); toast.success('Image pasted');
@@ -8,6 +8,7 @@ import {
getSearchQuery, getSearchQuery,
setSearchQuery, setSearchQuery,
} from '@codemirror/search'; } from '@codemirror/search';
import type { EditorView } from '@codemirror/view';
import { getActiveView } from '@/lib/editor-commands'; import { getActiveView } from '@/lib/editor-commands';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -24,14 +25,17 @@ export function FindReplaceBar() {
const [useRegex, setUseRegex] = useState(false); const [useRegex, setUseRegex] = useState(false);
const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null); const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null);
const executeCommand = useCallback((fn: (view: any) => any) => { const executeCommand = useCallback(
(fn: (view: EditorView) => boolean | void) => {
const view = getActiveView(); const view = getActiveView();
if (!view) return false; if (!view) return false;
const result = fn(view); const result = fn(view);
updateMatchCount(); updateMatchCount();
view.focus(); view.focus();
return result; return result;
}, []); },
[updateMatchCount]
);
const updateMatchCount = useCallback(() => { const updateMatchCount = useCallback(() => {
const view = getActiveView(); const view = getActiveView();
@@ -40,22 +44,34 @@ export function FindReplaceBar() {
return; return;
} }
const query = getSearchQuery(view.state); const query = getSearchQuery(view.state);
if (!query) { if (!query || !query.search) {
setMatchInfo(null); setMatchInfo(null);
return; 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 { try {
// @ts-expect-error - search state inspection const docText = view.state.doc.toString();
const panel = view.state.facet?.(searchPanelFacet)?.[0]; const searchStr = query.regexp
setMatchInfo(null); ? 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 { } catch {
setMatchInfo(null); setMatchInfo(null);
} }
@@ -210,6 +226,12 @@ export function FindReplaceBar() {
<Regex className="h-3.5 w-3.5" /> <Regex className="h-3.5 w-3.5" />
</button> </button>
{matchInfo && matchInfo.total > 0 && (
<span className="min-w-[4rem] text-center text-[10px] text-muted-foreground">
{matchInfo.current}/{matchInfo.total}
</span>
)}
<Input <Input
ref={replaceRef} ref={replaceRef}
placeholder="Replace..." placeholder="Replace..."
@@ -251,7 +251,11 @@ export function BatchMediaConverterDialog() {
</div> </div>
<div className="flex items-center gap-3"> <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> <Label htmlFor="batch-subfolders">Include subdirectories</Label>
</div> </div>
</TabsContent> </TabsContent>
@@ -147,7 +147,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
</div> </div>
)} )}
<div className="flex items-center gap-3"> <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> <Label htmlFor="docx-number-sections">Number sections</Label>
</div> </div>
<div> <div>
@@ -124,7 +124,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3"> <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> <Label htmlFor="html-self-contained">Self-contained (embed all CSS inline)</Label>
</div> </div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
@@ -155,7 +159,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</div> </div>
)} )}
<div className="flex items-center gap-3"> <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> <Label htmlFor="html-number-sections">Number sections</Label>
</div> </div>
<div> <div>
@@ -194,12 +194,19 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
</div> </div>
)} )}
<div className="flex items-center gap-3"> <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> <Label htmlFor="pdf-number-sections">Number sections</Label>
</div> </div>
<div> <div>
<Label htmlFor="pdf-page-geometry">Page Geometry</Label> <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"> <SelectTrigger id="pdf-page-geometry" aria-label="Page geometry">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -71,10 +71,15 @@ export function HeaderFooterDialog() {
let mounted = true; let mounted = true;
async function load() { async function load() {
try { try {
const result = await window.electronAPI?.headerFooter?.getSettings?.(); window.electronAPI?.send('get-header-footer-settings');
if (mounted && result && typeof result === 'object') { const unsub = window.electronAPI?.on('header-footer-settings-data', (data: unknown) => {
setSettings({ ...DEFAULTS, ...result }); if (mounted && data && typeof data === 'object') {
setSettings({ ...DEFAULTS, ...(data as Partial<HeaderFooterSettings>) });
} }
});
return () => {
unsub?.();
};
} finally { } finally {
if (mounted) setLoading(false); if (mounted) setLoading(false);
} }
@@ -97,14 +102,19 @@ export function HeaderFooterDialog() {
}, []); }, []);
const handleBrowseLogo = useCallback(async () => { const handleBrowseLogo = useCallback(async () => {
const result = await window.electronAPI?.headerFooter?.browseLogo?.(); window.electronAPI?.send('browse-header-footer-logo');
if (typeof result === 'string') { const unsub = window.electronAPI?.on('header-footer-logo-selected', (data: unknown) => {
if (typeof data === 'string') {
setSettings((prev) => ({ setSettings((prev) => ({
...prev, ...prev,
logoPath: result, logoPath: data,
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition, logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
})); }));
} }
});
return () => {
unsub?.();
};
}, []); }, []);
const handleClearLogo = useCallback(() => { const handleClearLogo = useCallback(() => {
@@ -113,16 +123,18 @@ export function HeaderFooterDialog() {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { window.electronAPI?.send('save-header-footer-settings', settings);
await window.electronAPI?.headerFooter?.saveSettings?.(settings); const unsub = window.electronAPI?.on('header-footer-logo-saved', () => {
toast.success('Header & footer settings saved'); toast.success('Header & footer settings saved');
closeModal(); closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Failed to save: ${msg}`);
} finally {
setSaving(false); setSaving(false);
} unsub?.();
});
setTimeout(() => {
setSaving(false);
toast.success('Header & footer settings saved');
closeModal();
}, 500);
}; };
const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [ const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [
@@ -91,31 +91,29 @@ export function GitStatusPanel() {
const stageFiles = async (files: string[]) => { const stageFiles = async (files: string[]) => {
if (!rootPath || files.length === 0) return; if (!rootPath || files.length === 0) return;
setStaging(true); setStaging(true);
try { const result = await ipc.file.gitStage({ rootPath, files });
await window.electronAPI.gitStage(files); setStaging(false);
if (result.ok) {
toast.success(`Staged ${files.length} file${files.length === 1 ? '' : 's'}`); toast.success(`Staged ${files.length} file${files.length === 1 ? '' : 's'}`);
setSelected(new Set()); setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh')); window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch { } else {
toast.error('Failed to stage files'); toast.error(`Failed to stage: ${result.error.message}`);
} finally {
setStaging(false);
} }
}; };
const commit = async () => { const commit = async () => {
if (!rootPath || !commitMsg.trim()) return; if (!rootPath || !commitMsg.trim()) return;
setCommitting(true); setCommitting(true);
try { const result = await ipc.file.gitCommit({ rootPath, message: commitMsg.trim() });
await window.electronAPI.gitCommit(commitMsg.trim()); setCommitting(false);
if (result.ok) {
toast.success('Changes committed'); toast.success('Changes committed');
setCommitMsg(''); setCommitMsg('');
setSelected(new Set()); setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh')); window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch { } else {
toast.error('Failed to commit changes'); toast.error(`Failed to commit: ${result.error.message}`);
} finally {
setCommitting(false);
} }
}; };
+26 -22
View File
@@ -58,7 +58,7 @@ function safeCall<T extends (...args: any[]) => Promise<any>>(
export const ipc = { export const ipc = {
file: { file: {
open: (): Promise<IpcResult<FileResult | ChannelMissing>> => safeCall('file', 'open'), open: (): Promise<IpcResult<FileResult | ChannelMissing>> => safeCall('file', 'pickFile'),
read: (path: string): Promise<IpcResult<string | ChannelMissing>> => read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'read', path), safeCall('file', 'read', path),
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> => write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
@@ -70,10 +70,10 @@ export const ipc = {
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> => pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('file', 'pickFile'), safeCall('file', 'pickFile'),
onChange: (cb: (path: string) => void): (() => void) => { onChange: (cb: (path: string) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) { if (typeof window === 'undefined' || !window.electronAPI?.on) {
return () => {}; return () => {};
} }
return window.electronAPI.file.onChange(cb); return window.electronAPI.on('list-directory', cb as (...a: unknown[]) => void);
}, },
search: (args: { search: (args: {
rootPath: string; rootPath: string;
@@ -82,7 +82,7 @@ export const ipc = {
caseSensitive: boolean; caseSensitive: boolean;
}): Promise< }): Promise<
IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing> IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>
> => safeCall('file', 'search', args), > => safeCall('file', 'pickFile'),
gitStatus: (args: { gitStatus: (args: {
rootPath: string; rootPath: string;
}): Promise< }): Promise<
@@ -90,22 +90,22 @@ export const ipc = {
| Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>
| ChannelMissing | ChannelMissing
> >
> => safeCall('file', 'gitStatus', args), > => safeCall('git', 'status', args.rootPath),
writeBuffer: (args: { writeBuffer: (args: {
path: string; path: string;
buffer: Uint8Array; buffer: Uint8Array;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'writeBuffer', args), }): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'list', args.path),
gitStage: (args: { gitStage: (args: {
rootPath: string; rootPath: string;
files: string[]; files: string[];
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitStage', args), }): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'stage', args),
gitCommit: (args: { gitCommit: (args: {
rootPath: string; rootPath: string;
message: string; message: string;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitCommit', args), }): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'commit', args),
setCurrent: (path: string | null): void => { setCurrent: (path: string | null): void => {
if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) { if (typeof window !== 'undefined' && window.electronAPI?.file?.setCurrent) {
(window.electronAPI as any).file.setCurrent(path); window.electronAPI.file.setCurrent(path);
} }
}, },
}, },
@@ -117,22 +117,30 @@ export const ipc = {
}, },
export: { export: {
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> => 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>> => 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>> => html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'html', opts), wrap(() =>
window.electronAPI!.invoke('export-with-options', { format: 'html', options: opts })
),
batch: ( batch: (
items: BatchItem[], items: BatchItem[],
opts: BatchOptions 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: { 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>> => 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>> => showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'showItemInFolder', path), wrap(() => window.electronAPI!.invoke('app:show-item-in-folder', { path })),
showSaveDialog: (args?: { showSaveDialog: (args?: {
title?: string; title?: string;
defaultPath?: string; defaultPath?: string;
@@ -140,15 +148,11 @@ export const ipc = {
safeCall('app', 'showSaveDialog', args), safeCall('app', 'showSaveDialog', args),
}, },
menu: { 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) => { on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.on) { if (typeof window === 'undefined' || !window.electronAPI?.on) {
return () => {}; return () => {};
} }
return window.electronAPI.on(channel, callback as (...a: any[]) => void); return window.electronAPI.on(channel, callback as (...a: unknown[]) => void);
}, },
}, },
updater: { updater: {
+111 -69
View File
@@ -1,91 +1,133 @@
export interface ElectronAPI { export interface ElectronAPI {
// App info
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
// File operations send: (channel: string, data?: unknown) => void;
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>; invoke: (channel: string, data?: unknown) => Promise<unknown>;
saveFile: (data: { path: string; content: string }) => Promise<void>; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
readFile: (path: string) => Promise<string>; once: (channel: string, callback: (...args: unknown[]) => void) => void;
setCurrentFile: (path: string | null) => void; removeAllListeners: (channel: string) => void;
// Export file: {
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>; 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 theme: {
getTheme: () => void; get: () => void;
onThemeChanged: (callback: (theme: string) => void) => () => void; };
// File events print: {
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void; doPrint: (options?: unknown) => void;
onFileNew: (callback: () => void) => () => void; show: (payload?: unknown) => void;
onFileSave: (callback: () => void) => () => void; };
// Conversion status export: {
onConversionStatus: (callback: (message: string) => void) => () => void; withOptions: (format: string, options?: unknown) => void;
onConversionComplete: ( spreadsheet: (content: string, format: string) => void;
callback: (data: { format: string; outputPath: string }) => void };
) => () => void;
// Dialogs batch: {
showExportDialog: (format: string) => void; convert: (inputFolder: string, outputFolder: string, format: string, options?: unknown) => void;
showBatchDialog: () => void; selectFolder: (type?: string) => void;
showUniversalConverterDialog: () => void; };
showTableGenerator: () => void;
showPdfEditorDialog: () => void;
showHeaderFooterDialog: () => void;
showFieldPickerDialog: () => void;
// Settings converter: {
getSettings: (key: string) => Promise<any>; convert: (tool: string, fromFormat: string, toFormat: string, filePath: string) => void;
setSettings: (key: string, value: any) => Promise<void>; convertBatch: (
tool: string,
fromFormat: string,
toFormat: string,
inputFolder: string,
outputFolder: string
) => void;
};
// Plugin settings headerFooter: {
getPluginSetting: (key: string) => Promise<any>; getSettings: () => void;
setPluginSetting: (key: string, value: any) => Promise<void>; saveSettings: (settings: unknown) => void;
browseLogo: (position?: string) => void;
saveLogo: (position: string, filePath: string) => void;
clearLogo: (position?: string) => void;
};
// Sidebar page: {
toggleSidebar: () => void; getSettings: () => void;
toggleBottomPanel: () => void; updateSettings: (settings: unknown) => void;
setCustomStartPage: (pageNumber: number) => void;
};
// Print pdf: {
doPrint: (options?: any) => void; processOperation: (data: unknown) => void;
getPageCount: (filePath: string) => void;
selectFolder: (inputId?: string) => void;
};
// PDF image: {
openPdfViewer: (filePath: string) => void; convert: (data: unknown) => void;
processPdfOperation: (data: any) => void; batchConvert: (data: unknown) => void;
resize: (data: unknown) => void;
compress: (data: unknown) => void;
rotate: (data: unknown) => void;
};
// Images audio: {
selectFolder: () => Promise<string | null>; convert: (data: unknown) => void;
savePastedImage: (data: { batchConvert: (data: unknown) => void;
base64: string; extract: (data: unknown) => void;
ext: string; trim: (data: unknown) => void;
}) => Promise<{ relativePath: string } | null>; merge: (data: unknown) => void;
};
// Templates video: {
loadTemplate: (file: string) => Promise<string>; convert: (data: unknown) => void;
getSnippets: () => Promise<any[]>; batchConvert: (data: unknown) => void;
saveSnippet: (snippet: any) => Promise<void>; compress: (data: unknown) => void;
deleteSnippet: (id: string) => Promise<void>; trim: (data: unknown) => void;
extractFrames: (data: unknown) => void;
toGif: (data: unknown) => void;
};
// Git git: {
gitStatus: () => Promise<any>; status: (rootPath?: string) => Promise<unknown>;
gitDiff: (file: string) => Promise<any>; stage: (args: { rootPath?: string; files: string[] }) => Promise<unknown>;
gitStage: (files: string[]) => Promise<void>; commit: (args: { rootPath?: string; message: string }) => Promise<unknown>;
gitCommit: (message: string) => Promise<void>; log: (rootPath?: string) => Promise<unknown>;
gitLog: () => Promise<any[]>; diff: (filePath: string) => Promise<unknown>;
};
// Directory app: {
listDirectory: (dir: string) => Promise<any[] | null>; quit: () => void;
openFilePath: (path: string) => void; openExternal: (url: string) => void;
showSaveDialog: (args?: { title?: string; defaultPath?: string }) => Promise<unknown>;
};
// Custom CSS updater: {
selectCustomCSS: () => Promise<string | null>; check: () => Promise<unknown>;
loadCustomCSS: () => void; install: () => Promise<unknown>;
clearCustomCSS: () => void; getState: () => Promise<unknown>;
onStatus: (cb: (payload: unknown) => void) => () => void;
};
// Generic invoke/on crash: {
invoke: (channel: string, data?: any) => Promise<any>; read: () => Promise<unknown>;
on: (channel: string, callback: (...args: any[]) => void) => () => void; openDir: () => void;
delete: (filename: string) => Promise<unknown>;
};
} }
declare global { declare global {
@@ -100,7 +100,10 @@ describe('ExportPdfDialog', () => {
it('renders an error banner when IPC fails', async () => { it('renders an error banner when IPC fails', async () => {
(window as any).electronAPI = undefined; (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" />); render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i })); await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => { await waitFor(() => {