diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index ad3cb95..064f47f 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -21,8 +21,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -51,8 +49,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 952dc5b..90fd5bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ on: permissions: contents: write + +env: + HUSKY: 0 jobs: # add change set diff --git a/apps/main-processor/src/export/exportDocx.ts b/apps/main-processor/src/export/exportDocx.ts index b51b3b7..b01e28f 100644 --- a/apps/main-processor/src/export/exportDocx.ts +++ b/apps/main-processor/src/export/exportDocx.ts @@ -1,10 +1,12 @@ import HTMLtoDOCX from 'html-to-docx'; import { writeFile } from 'node:fs/promises'; import { buildDocument } from './buildDocument'; +import { inlineImages } from './inlineImage'; import { sanitizeCss } from './sanitizeCss'; export async function exportDOCX(bodyHtml: string, css: string, outputPath: string): Promise { - const html = buildDocument(bodyHtml, sanitizeCss(css)); + const htmlWithInlineImages = await inlineImages(bodyHtml); + const html = buildDocument(htmlWithInlineImages, sanitizeCss(css)); const result = await HTMLtoDOCX(html, null, { table: { row: { cantSplit: true } }, diff --git a/apps/main-processor/src/export/exportPdf.ts b/apps/main-processor/src/export/exportPdf.ts index 058619a..1912017 100644 --- a/apps/main-processor/src/export/exportPdf.ts +++ b/apps/main-processor/src/export/exportPdf.ts @@ -8,7 +8,7 @@ export async function exportPDF(bodyHtml: string, css: string, outputPath: strin const pdfWindow = new BrowserWindow({ show: false, webPreferences: { - sandbox: false, + sandbox: true, }, }); diff --git a/apps/main-processor/src/export/sanitizeCss.ts b/apps/main-processor/src/export/sanitizeCss.ts index b58640e..04ee48d 100644 --- a/apps/main-processor/src/export/sanitizeCss.ts +++ b/apps/main-processor/src/export/sanitizeCss.ts @@ -2,9 +2,14 @@ import { EXPORT_CONST } from '../utils/constants/export-constants'; export function sanitizeCss(css: string): string { if (!css || typeof css !== 'string') return ''; - const sanitized = EXPORT_CONST.DANGEROUS_CSS_PATTERNS.reduce( - (safeCss, pattern) => safeCss.replace(pattern, ''), - css - ); + let sanitized = css; + let previous: string; + do { + previous = sanitized; + sanitized = EXPORT_CONST.DANGEROUS_CSS_PATTERNS.reduce( + (safeCss, pattern) => safeCss.replace(pattern, ''), + sanitized + ); + } while (sanitized !== previous); return sanitized; } diff --git a/apps/main-processor/src/file.ts b/apps/main-processor/src/file.ts index 6f2670d..8ba0509 100644 --- a/apps/main-processor/src/file.ts +++ b/apps/main-processor/src/file.ts @@ -34,8 +34,26 @@ export async function watchFile( pollInterval: 50, }, }); - await new Promise((resolve) => { - watcher.on('ready', resolve); + currentWatchers.set(filePath, watcher); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve(); + }, 1000); + watcher.once('ready', () => { + clearTimeout(timeout); + resolve(); + }); + + watcher.once('error', (error) => { + clearTimeout(timeout); + const watcherError = error instanceof Error ? error : new Error(String(error)); + + void unWatchFile(filePath) + .catch(() => {}) + .finally(() => onError?.(watcherError)); + + reject(watcherError); + }); }); watcher.on('change', () => { const existingTimer = debounceTimers.get(filePath); @@ -54,13 +72,10 @@ export async function watchFile( debounceTimers.set(filePath, timer); }); watcher.on('unlink', () => { - void unWatchFile(filePath).then(() => onDeleted?.()); - }); - watcher.on('error', (error) => { - const watcherError = error instanceof Error ? error : new Error(String(error)); - void unWatchFile(filePath).then(() => onError?.(watcherError)); + void unWatchFile(filePath) + .catch(() => {}) + .finally(() => onDeleted?.()); }); - currentWatchers.set(filePath, watcher); } //unwatch file diff --git a/apps/main-processor/src/ipc.ts b/apps/main-processor/src/ipc.ts index 6410d76..76c28ee 100644 --- a/apps/main-processor/src/ipc.ts +++ b/apps/main-processor/src/ipc.ts @@ -1,7 +1,12 @@ import { app, ipcMain, dialog } from 'electron'; import { readFile, unWatchFile, watchFile } from './file'; import { getFolder } from './folder'; -import { validatePath, validateSender, allowedFolderRoots } from './utils/constants/ipc-validation'; +import { + validatePath, + validateSender, + allowedFolderRoots, + allowedMarkdownFiles, +} from './utils/constants/ipc-validation'; import { IPC_CONSTANTS } from '@package/shared-constants'; import { getRecentFiles } from './recent/getRecentFile'; import { addRecentFile } from './recent/addRecentFile'; @@ -26,6 +31,7 @@ export function registerIPCHandlers(): void { throw new Error('Untrusted sender'); } const safeFilePath = await resolveMarkdownFilePath(filePath); + allowedMarkdownFiles.add(safeFilePath); return await readFile(safeFilePath); }); @@ -47,7 +53,9 @@ export function registerIPCHandlers(): void { } const selected = result.filePaths[0]; if (!selected) return null; - return await resolveMarkdownFilePath(selected); + const safeFilePath = await resolveMarkdownFilePath(selected); + allowedMarkdownFiles.add(safeFilePath); + return safeFilePath; }); // watches a file @@ -199,9 +207,7 @@ export function registerIPCHandlers(): void { if (!validateSender(event)) { throw new Error('Untrusted sender'); } - const safeFolderPath = await resolveDirectoryPath(folderPath); - allowedFolderRoots.add(safeFolderPath); const isAllowed = Array.from(allowedFolderRoots).some( (root) => safeFolderPath === root || safeFolderPath.startsWith(`${root}/`) ); diff --git a/apps/main-processor/src/utils/constants/ipc-validation.ts b/apps/main-processor/src/utils/constants/ipc-validation.ts index 7a28049..13d2238 100644 --- a/apps/main-processor/src/utils/constants/ipc-validation.ts +++ b/apps/main-processor/src/utils/constants/ipc-validation.ts @@ -2,14 +2,27 @@ import path from 'path'; import { IpcMainInvokeEvent } from 'electron'; // production and dev urls -const allowedOrigin = ['file://', 'http://localhost']; export const ALLOWED_MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); export const allowedFolderRoots = new Set(); +export const allowedMarkdownFiles = new Set(); //validate the sender export function validateSender(event: IpcMainInvokeEvent): boolean { - const url = event.senderFrame?.url || ''; - return allowedOrigin.some((origin) => url.startsWith(origin)); + const url = event.senderFrame?.url; + + if (!url) return false; + + try { + const parsedUrl = new URL(url); + + if (parsedUrl.protocol === 'file:') { + return true; + } + + return parsedUrl.protocol === 'http:' && parsedUrl.hostname === 'localhost'; + } catch { + return false; + } } //validate path type diff --git a/apps/main-processor/src/utils/helper/ipc-path-resolver.ts b/apps/main-processor/src/utils/helper/ipc-path-resolver.ts index 2212f78..916556c 100644 --- a/apps/main-processor/src/utils/helper/ipc-path-resolver.ts +++ b/apps/main-processor/src/utils/helper/ipc-path-resolver.ts @@ -5,6 +5,7 @@ import { isPathInside, ALLOWED_MARKDOWN_EXTENSIONS, allowedFolderRoots, + allowedMarkdownFiles, } from '../constants/ipc-validation'; /* Validates, resolves symlink and ensures a path is a valid Markdown file inside an optional root directory */ @@ -54,16 +55,17 @@ export async function resolveDirectoryPath(folderPath: string): Promise /*Resolves a Markdown file path against a dynamic set of allowed root folders throwing an error if it matches none*/ export async function resolveWatchedMarkdownPath(filePath: string): Promise { - let lastError: unknown; + const safeFilePath = await resolveMarkdownFilePath(filePath); + + if (allowedMarkdownFiles.has(safeFilePath)) { + return safeFilePath; + } for (const allowedRoot of allowedFolderRoots) { try { return await resolveMarkdownFilePath(filePath, allowedRoot); - } catch (error) { - lastError = error; + } catch { + continue; } } - if (allowedFolderRoots.size > 0) { - throw lastError instanceof Error ? lastError : new Error('Path escapes allowed directory'); - } - return await resolveMarkdownFilePath(filePath); + throw new Error('Path escapes allowed directory'); } diff --git a/apps/preload/src/index.ts b/apps/preload/src/index.ts index 8c73d60..8969574 100644 --- a/apps/preload/src/index.ts +++ b/apps/preload/src/index.ts @@ -54,9 +54,11 @@ const apiContract: MarkdownReaderAPI = { getPathForFile: (file: File) => webUtils.getPathForFile(file), onUpdateAvailable: (callback: (version: string) => void) => { - ipcRenderer.on(IPC_CONSTANTS.UPDATE_AVAILABLE, (_event, version: string) => { - callback(version); - }); + const handler = (_event: IpcRendererEvent, version: string) => callback(version); + ipcRenderer.on(IPC_CONSTANTS.UPDATE_AVAILABLE, handler); + return () => { + ipcRenderer.removeListener(IPC_CONSTANTS.UPDATE_AVAILABLE, handler); + }; }, downloadUpdate: () => ipcRenderer.send(IPC_CONSTANTS.DOWNLOAD_UPDATE), }; diff --git a/apps/renderer/__tests__/components/TabBar.test.tsx b/apps/renderer/__tests__/components/TabBar.test.tsx index 2b10148..d93c128 100644 --- a/apps/renderer/__tests__/components/TabBar.test.tsx +++ b/apps/renderer/__tests__/components/TabBar.test.tsx @@ -31,7 +31,7 @@ describe('TabBar', () => { it('marks the active tab', () => { render( {}} onClose={() => {}} />); - expect(screen.getByRole('tab', { name: /README/i })).toHaveAttribute('aria-current', 'true'); + expect(screen.getByRole('tab', { name: /README/i })).toHaveAttribute('aria-selected', 'true'); }); it('switches tab when a tab is clicked', () => { @@ -49,7 +49,7 @@ describe('TabBar', () => { render( {}} onClose={onClose} />); - fireEvent.click(screen.getAllByRole('button', { name: /close tab/i })[0]); + fireEvent.click(screen.getAllByRole('button', { name: /close .* tab/i })[0]); expect(onClose).toHaveBeenCalledWith('tab-1'); }); diff --git a/apps/renderer/__tests__/hooks/useSettings.test.ts b/apps/renderer/__tests__/hooks/useSettings.test.ts index 2caea62..4144de6 100644 --- a/apps/renderer/__tests__/hooks/useSettings.test.ts +++ b/apps/renderer/__tests__/hooks/useSettings.test.ts @@ -12,34 +12,45 @@ describe('useSettings font size', () => { expect(result.current.fontSize).toBe(16); }); - it('should increase font size by adding 2px', () => { + it('should increase font size by adding 2px', async () => { const { result } = renderHook(() => useSettings()); - act(() => result.current.increaseFontSize()); + await act(async () => { + result.current.increaseFontSize(); + }); expect(result.current.fontSize).toBe(18); }); - it('should decrease font size by subracting 2px', () => { + it('should decrease font size by subracting 2px', async () => { const { result } = renderHook(() => useSettings()); - act(() => result.current.decreaseFontSize()); + await act(async () => { + result.current.decreaseFontSize(); + }); expect(result.current.fontSize).toBe(14); }); - it('should not exceed 24px', () => { + it('should not exceed 24px', async () => { const { result } = renderHook(() => useSettings()); - act(() => { - for (let i = 0; i < 10; i++) { + + for (let i = 0; i < 10; i++) { + await act(async () => { result.current.increaseFontSize(); - } - }); + }); + } + expect(result.current.fontSize).toBe(24); }); - it('should return recent font size as 16', () => { + it('should return recent font size as 16', async () => { const { result } = renderHook(() => useSettings()); - act(() => { + + await act(async () => { result.current.increaseFontSize(); + }); + + await act(async () => { result.current.resetFontSize(); }); + expect(result.current.fontSize).toBe(16); }); }); diff --git a/apps/renderer/setup.ts b/apps/renderer/setup.ts index 9f3f123..3cb5ddf 100644 --- a/apps/renderer/setup.ts +++ b/apps/renderer/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import { vi } from 'vitest'; // mock svg measurments for jsdom environment for mermaid testing if (typeof window !== 'undefined' && window.SVGElement) { @@ -20,3 +21,20 @@ if (typeof window !== 'undefined' && window.SVGElement) { proto.getComputedTextLength = () => 100; } } + +let mockSettings = { + fontSize: 16, + readingWidth: 'default', + customCss: '', +}; + +Object.defineProperty(window, 'api', { + value: { + getSettings: vi.fn(async () => mockSettings), + saveSettings: vi.fn(async (partial) => { + mockSettings = { ...mockSettings, ...partial }; + return mockSettings; + }), + }, + writable: true, +}); diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 7d209ca..00cf48f 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React,{ useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useFile } from './hooks/useFile'; import { Welcome } from './components/Welcome'; import { Reader } from './components/Reader'; @@ -31,6 +31,7 @@ import { useFilePersistence } from './hooks/useFilePersistence'; import { ReaderToolbar } from './components/ReaderToolbar'; import { useFolderSearch } from './hooks/useFolderSearch'; import { SettingsPanel } from './components/SettingsPanel'; +import { ErrorBoundary } from './components/ErrorBoundary'; export default function App() { const { error, isLoading, openFile, toc,recentFiles,loadFile } =useFile(); @@ -162,6 +163,7 @@ useShortcuts({ activeTabId={state.activeTabId} onSwitch={(id) => dispatch({ type: 'SWITCH_TAB', payload: { tabId: id } })} onClose={(id) => dispatch({ type: 'CLOSE_TAB', payload: { tabId: id } })} + plusOpen={openFileDialog} /> )} @@ -211,7 +213,9 @@ useShortcuts({ className="flex-1 overflow-y-auto" onScroll={scroll} > - + + + )} diff --git a/apps/renderer/src/components/DragDrop.tsx b/apps/renderer/src/components/DragDrop.tsx index deccd80..4e36766 100644 --- a/apps/renderer/src/components/DragDrop.tsx +++ b/apps/renderer/src/components/DragDrop.tsx @@ -4,7 +4,7 @@ when a file is dragged and dropped on the application */ export function DragDrop() { return ( -
+
Drop Markdown file to open
diff --git a/apps/renderer/src/components/Error.tsx b/apps/renderer/src/components/Error.tsx index 180d108..e050b3f 100644 --- a/apps/renderer/src/components/Error.tsx +++ b/apps/renderer/src/components/Error.tsx @@ -3,9 +3,10 @@ import { ErrorProps } from "../types/component-types" //error message display function export function Error({message,onRetry}:ErrorProps){ return ( -
+

Error: {message}

+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/renderer/src/components/FileBrowser.tsx b/apps/renderer/src/components/FileBrowser.tsx index cdd83ca..0153a55 100644 --- a/apps/renderer/src/components/FileBrowser.tsx +++ b/apps/renderer/src/components/FileBrowser.tsx @@ -1,6 +1,5 @@ import { FileBrowserProps } from '../types/component-types'; import { FileTree } from './FileTree'; -import { Icons } from '../utils/constants/icon-contants'; export function FileBrowser({ tree, @@ -12,11 +11,11 @@ export function FileBrowser({ return (