diff --git a/src/main/fileOperations.ts b/src/main/fileOperations.ts index 491db0c..42aa2ca 100644 --- a/src/main/fileOperations.ts +++ b/src/main/fileOperations.ts @@ -13,6 +13,12 @@ type FolderTree = { children?: FolderTree[]; }; +// Track the currently opened folder for security checks +let currentWorkspaceRoot: string | null = null; + +// Track previously opened files outside the workspace +const openedFiles = new Set(); + async function readTree(dirPath: string): Promise { const stats = await fsp.stat(dirPath); if (!stats.isDirectory()) return []; @@ -35,7 +41,7 @@ async function readTree(dirPath: string): Promise { type: "file" as const, }; } - }) + }), ); return children; } @@ -48,6 +54,7 @@ export async function handleOpenFolder( }); if (!result.canceled && result.filePaths.length > 0) { const folderPath = result.filePaths[0]; + currentWorkspaceRoot = folderPath; // Track the opened folder return { name: path.basename(folderPath), path: folderPath, @@ -57,3 +64,217 @@ export async function handleOpenFolder( } return null; } + +// Security helper: Check if a path is within the current workspace or previously opened +function isPathSecure(filePath: string): boolean { + const resolvedPath = path.resolve(filePath); + + // Allow if within workspace + if (currentWorkspaceRoot) { + const resolvedRoot = path.resolve(currentWorkspaceRoot); + + if ( + resolvedPath.startsWith(resolvedRoot + path.sep) || + resolvedPath === resolvedRoot + ) { + return true; + } + } + + // Allow if previously opened + if (openedFiles.has(resolvedPath)) { + return true; + } + + return false; +} + +// File Operations +export async function handleReadFile( + mainWindow: BrowserWindow, + filePath?: string, +): Promise<{ content: string; path: string } | null> { + let targetPath: string; + + if (filePath) { + // If path is provided, check if it's secure + if (!isPathSecure(filePath)) { + throw new Error( + "Access denied: File is outside the workspace and not previously opened", + ); + } + targetPath = filePath; + } else { + // Show file dialog + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openFile"], + defaultPath: currentWorkspaceRoot || undefined, + // filters: [ + // { name: "All Files", extensions: ["*"] }, + // { + // name: "Text Files", + // extensions: [ + // "txt", + // "md", + // "js", + // "ts", + // "json", + // "html", + // "css", + // ], + // }, + // ], + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + targetPath = result.filePaths[0]; + + // Track files opened via dialog to allow future access + openedFiles.add(path.resolve(targetPath)); + } + + try { + const content = await fsp.readFile(targetPath, "utf8"); + return { content, path: targetPath }; + } catch (error) { + throw new Error(`Failed to read file: ${error.message}`); + } +} + +export async function handleSaveFile( + mainWindow: BrowserWindow, + content: string, + filePath?: string, +): Promise<{ path: string } | null> { + let targetPath: string; + + if (filePath) { + // If path is provided, check if it's secure + if (!isPathSecure(filePath)) { + throw new Error( + "Access denied: File is outside the workspace and not previously opened", + ); + } + targetPath = filePath; + } else { + // Show save dialog + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: currentWorkspaceRoot || undefined, + // filters: [ + // { name: "All Files", extensions: ["*"] }, + // { + // name: "Text Files", + // extensions: [ + // "txt", + // "md", + // "js", + // "ts", + // "json", + // "html", + // "css", + // ], + // }, + // ], + }); + + if (result.canceled || !result.filePath) { + return null; + } + + targetPath = result.filePath; + + // Track files saved via dialog (always allowed) + openedFiles.add(path.resolve(targetPath)); + } + + try { + // Ensure directory exists + const dir = path.dirname(targetPath); + await fsp.mkdir(dir, { recursive: true }); + + await fsp.writeFile(targetPath, content, "utf8"); + return { path: targetPath }; + } catch (error) { + throw new Error(`Failed to save file: ${error.message}`); + } +} + +// export async function handleCreateFile( +// mainWindow: BrowserWindow, +// fileName: string, +// content = "", +// directory?: string, +// ): Promise<{ path: string } | null> { +// let targetDir: string; + +// if (directory) { +// // If directory is provided, check if it's secure +// if (!isPathSecure(directory)) { +// throw new Error( +// "Access denied: Directory is outside the workspace", +// ); +// } +// targetDir = directory; +// } else if (currentWorkspaceRoot) { +// // Use current workspace root +// targetDir = currentWorkspaceRoot; +// } else { +// // Show directory selection dialog +// const result = await dialog.showOpenDialog(mainWindow, { +// properties: ["openDirectory"], +// title: "Select directory to create file in", +// }); + +// if (result.canceled || result.filePaths.length === 0) { +// return null; +// } + +// targetDir = result.filePaths[0]; +// } + +// const targetPath = path.join(targetDir, fileName); + +// // Double-check the final path is secure +// if (!isPathSecure(targetPath)) { +// throw new Error("Access denied: Target path is outside the workspace"); +// } + +// try { +// // Check if file already exists +// const exists = await fsp +// .access(targetPath) +// .then(() => true) +// .catch(() => false); +// if (exists) { +// // Ask user if they want to overwrite +// const choice = await dialog.showMessageBox(mainWindow, { +// type: "question", +// buttons: ["Cancel", "Overwrite"], +// defaultId: 0, +// message: `File "${fileName}" already exists. Do you want to overwrite it?`, +// }); + +// if (choice.response === 0) { +// return null; // User cancelled +// } +// } + +// await fsp.writeFile(targetPath, content, "utf8"); +// return { path: targetPath }; +// } catch (error) { +// throw new Error(`Failed to create file: ${error.message}`); +// } +// } + +// Utility function to get current workspace info +export function getCurrentWorkspace(): { root: string | null } { + return { root: currentWorkspaceRoot }; +} + +// Utility function to get opened files (for debugging/info) +export function getOpenedFiles(): string[] { + return Array.from(openedFiles); +} diff --git a/src/main/main.ts b/src/main/main.ts index 0420e0c..43df5b4 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,5 +1,12 @@ import { app, BrowserWindow, ipcMain } from "electron"; -import { handleOpenFolder } from "./fileOperations"; +import { + handleOpenFolder, + handleReadFile, + handleSaveFile, + // handleCreateFile, + getCurrentWorkspace, + getOpenedFiles, +} from "./fileOperations"; import path from "node:path"; import started from "electron-squirrel-startup"; @@ -44,6 +51,34 @@ app.whenReady().then(() => { const senderWindow = BrowserWindow.fromWebContents(event.sender); return await handleOpenFolder(senderWindow); }); + + // File operation handlers + ipcMain.handle("file:read", async (event, filePath?: string) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + return await handleReadFile(senderWindow, filePath); + }); + + ipcMain.handle( + "file:save", + async (event, content: string, filePath?: string) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + return await handleSaveFile(senderWindow, content, filePath); + }, + ); + + // ipcMain.handle("file:create", async (event, fileName: string, content = '', directory?: string) => { + // const senderWindow = BrowserWindow.fromWebContents(event.sender); + // return await handleCreateFile(senderWindow, fileName, content, directory); + // }); + + ipcMain.handle("workspace:getCurrentInfo", () => { + return getCurrentWorkspace(); + }); + + ipcMain.handle("workspace:getOpenedFiles", () => { + return getOpenedFiles(); + }); + createWindow(); if (process.platform === "darwin") { app.on("activate", function () { diff --git a/src/preload.ts b/src/preload.ts index 97a4d39..b8db91d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -7,4 +7,32 @@ import type { FolderTree } from "./types/global"; contextBridge.exposeInMainWorld("electronAPI", { openFolder: () => ipcRenderer.invoke("dialog:openFolder") as Promise, + + // File operations + readFile: (filePath?: string) => + ipcRenderer.invoke("file:read", filePath) as Promise<{ + content: string; + path: string; + } | null>, + + saveFile: (content: string, filePath?: string) => + ipcRenderer.invoke("file:save", content, filePath) as Promise<{ + path: string; + } | null>, + + // createFile: (fileName: string, content = "", directory?: string) => + // ipcRenderer.invoke( + // "file:create", + // fileName, + // content, + // directory, + // ) as Promise<{ path: string } | null>, + + getCurrentWorkspace: () => + ipcRenderer.invoke("workspace:getCurrentInfo") as Promise<{ + root: string | null; + }>, + + getOpenedFiles: () => + ipcRenderer.invoke("workspace:getOpenedFiles") as Promise, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0940b40..b693c91 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -13,7 +13,20 @@ declare global { interface Window { electronAPI: { openFolder: () => Promise; - // Add other methods as needed + + // File operations + readFile: ( + filePath?: string, + ) => Promise<{ content: string; path: string } | null>; + saveFile: ( + content: string, + filePath?: string, + ) => Promise<{ path: string } | null>; + // createFile: (fileName: string, content?: string, directory?: string) => Promise<{ path: string } | null>; + + // Workspace info + getCurrentWorkspace: () => Promise<{ root: string | null }>; + getOpenedFiles: () => Promise; }; } }