diff --git a/.gitignore b/.gitignore index 8df8697..03b0330 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ json/ dist/ build/ *.log -.env -package-lock.json \ No newline at end of file +.env \ No newline at end of file diff --git a/app/auth.js b/app/auth.js index 9ad956c..daf2af5 100644 --- a/app/auth.js +++ b/app/auth.js @@ -1,4 +1,4 @@ -const { ipcMain, app } = require('electron'); +const { ipcMain, app, safeStorage } = require('electron'); const fs = require('fs'); const path = require('path'); const { LOCAL_FILE_PATH } = require("./main/helpers/paths.js") @@ -139,7 +139,14 @@ async function verifyToken(token) { function saveToken(tokenData) { try { - fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2)); + const raw = JSON.stringify(tokenData); + if (safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(raw); + fs.writeFileSync(tokenFile, encrypted); + } else { + console.warn('[Auth] safeStorage is not available, falling back to plaintext token storage'); + fs.writeFileSync(tokenFile, raw); + } return true; } catch (error) { console.error('Error saving token:', error); @@ -149,11 +156,20 @@ function saveToken(tokenData) { function loadToken() { try { - if (fs.existsSync(tokenFile)) { - const data = fs.readFileSync(tokenFile, 'utf-8'); - return JSON.parse(data); + if (!fs.existsSync(tokenFile)) { + return null; } - return null; + const buffer = fs.readFileSync(tokenFile); + const text = buffer.toString('utf-8'); + // Legacy plaintext fallback: files starting with '{' were written unencrypted + if (text.trimStart().startsWith('{')) { + return JSON.parse(text); + } + if (safeStorage.isEncryptionAvailable()) { + const decrypted = safeStorage.decryptString(buffer); + return JSON.parse(decrypted); + } + return JSON.parse(text); } catch (error) { console.error('Error loading token:', error); return null; @@ -372,7 +388,9 @@ ipcMain.handle("set-non-account-mode", async (_, value = true) => { data.nonAccountMode = value try { - fs.writeFileSync(LOCAL_FILE_PATH, JSON.stringify(data, null, 4), "utf-8") + const tempPath = LOCAL_FILE_PATH + ".tmp" + fs.writeFileSync(tempPath, JSON.stringify(data, null, 4), "utf-8") + fs.renameSync(tempPath, LOCAL_FILE_PATH) return { ok: true } } catch (e) { return { ok: false } diff --git a/app/electron/live-server.js b/app/electron/live-server.js index 45461d0..9e0dd35 100644 --- a/app/electron/live-server.js +++ b/app/electron/live-server.js @@ -19,22 +19,47 @@ ipcMain.handle("start-live-server", async (event, htmlPath) => { } const root = path.dirname(htmlPath) - const port = 3000 - const wsPort = 3001 + + function getMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase() + const map = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' + } + return map[ext] || 'application/octet-stream' + } function inject(html) { const script = ` ` - return html.replace("", script + "") + return html.replace(/<\/body>/i, script + "") } liveServer = http.createServer((req, res) => { let filePath = path.join(root, req.url === "/" ? path.basename(htmlPath) : req.url) + const resolvedFile = path.resolve(filePath) + const resolvedRoot = path.resolve(root) + if (!resolvedFile.startsWith(resolvedRoot + path.sep)) { + res.writeHead(403) + return res.end("Forbidden") + } fs.readFile(filePath, (err, data) => { @@ -47,33 +72,52 @@ ipcMain.handle("start-live-server", async (event, htmlPath) => { data = inject(data.toString()) } - res.writeHead(200) + res.writeHead(200, { 'Content-Type': getMimeType(filePath) }) res.end(data) }) }) - liveServer.listen(port) - - wss = new WebSocket.Server({ port: wsPort }) - - watcher = chokidar.watch(root).on("change", () => { - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send("reload") - } - }) - + let port = 3000 + let wsPort = 3001 + return new Promise((resolve) => { + function tryListen(attempt = 0) { + liveServer.listen(port, () => { + const addr = liveServer.address() + if (addr && typeof addr === 'object') { + port = addr.port + wsPort = port + 1 + } + + const wsServer = http.createServer() + wss = new WebSocket.Server({ server: wsServer }) + wsServer.listen(wsPort) + + watcher = chokidar.watch(root).on("change", () => { + wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send("reload") + } + }) + }) + + const url = `http://localhost:${port}` + shell.openExternal(url) + resolve({ success: true, url }) + }) + liveServer.on("error", (err) => { + if (err.code === "EADDRINUSE" && attempt < 10) { + port++ + wsPort++ + tryListen(attempt + 1) + } else { + console.error("[Live Server] Failed to start:", err) + resolve({ error: err.message }) + } + }) + } + tryListen() }) - - const url = `http://localhost:${port}` - - shell.openExternal(url) - - return { - success: true, - url - } }) ipcMain.handle("stop-live-server", async () => { diff --git a/app/main/helpers/os.js b/app/main/helpers/os.js index 7cf414f..f23ee24 100644 --- a/app/main/helpers/os.js +++ b/app/main/helpers/os.js @@ -64,7 +64,7 @@ async function readDirTree(rootPath, options = {}) { entries.sort((a, b) => { if (a.type === b.type) { - return a.name.localeCompare(b.name); + return a.name.localeCompare(b.name) || 0; } if (a.type === 'dir') return -1; if (b.type === 'dir') return 1; diff --git a/app/main/helpers/requests.js b/app/main/helpers/requests.js index ced3c49..87b5221 100644 --- a/app/main/helpers/requests.js +++ b/app/main/helpers/requests.js @@ -143,8 +143,8 @@ function getPackageData() { return {}; } } -async function getAppIcon() { - const settings = await readSettings() +function getAppIcon() { + const settings = readSettings() if ("app" in settings) { if ("icon" in settings.app) { @@ -183,12 +183,11 @@ async function readFileContent(filePath, encoding = 'utf8') { ? filePath : path.join(app.getAppPath(), filePath); - const abs = path.resolve(base, filePath); - const data = await fsPromise.readFile(abs, { encoding: encoding === null ? undefined : encoding }); + const data = await fsPromise.readFile(base, { encoding: encoding === null ? undefined : encoding }); return data; } function updateLocalAppData(newData) { - const filePath = path.join(__dirname, "local.json"); + const filePath = LOCAL_FILE_PATH; let currentData = {}; if (fs.existsSync(filePath)) { diff --git a/app/main/helpers/terminal.js b/app/main/helpers/terminal.js index c9519c7..14c8d34 100644 --- a/app/main/helpers/terminal.js +++ b/app/main/helpers/terminal.js @@ -40,14 +40,16 @@ class TerminalManager { handleOutput(data, type, event) { const output = data.toString(); const prefix = type === 'stderr' ? '[ERR] ' : ''; - + console.log(`[Terminal ${type}] ${output}`); - - event.sender.send("terminal-result", { - type: type === 'stderr' ? 'error' : 'output', - data: prefix + output, - timestamp: Date.now() - }); + + if (!event.sender.isDestroyed()) { + event.sender.send("terminal-result", { + type: type === 'stderr' ? 'error' : 'output', + data: prefix + output, + timestamp: Date.now() + }); + } } cleanupInputHandler() { @@ -86,7 +88,32 @@ class TerminalManager { } executeCommand(event, data) { - const { cmd, cwd } = data; + let { cmd, cwd } = data; + + if (typeof cmd !== 'string') { + event.sender.send("terminal-result", { + type: 'error', + data: 'Invalid command: must be a string\r\n' + }); + return; + } + + cmd = cmd.trim(); + if (!cmd) { + event.sender.send("terminal-result", { + type: 'error', + data: 'Empty command\r\n' + }); + return; + } + + if (cmd.length > 5000) { + event.sender.send("terminal-result", { + type: 'error', + data: 'Command too long (max 5000 chars)\r\n' + }); + return; + } if (this.activeProcess) { event.sender.send("terminal-result", { @@ -163,9 +190,10 @@ class TerminalManager { this.inputHandler = (e, input) => { if (this.activeProcess && !this.activeProcess.killed) { try { - const inputWithNewline = input.endsWith('\n') ? input : input + '\n'; + const str = String(input ?? ''); + const inputWithNewline = str.endsWith('\n') ? str : str + '\n'; this.activeProcess.stdin.write(inputWithNewline); - console.log(`[Terminal] Sent input: ${input}`); + console.log(`[Terminal] Sent input: ${str}`); } catch (err) { console.error("Error writing to stdin:", err.message); event.sender.send("terminal-result", { diff --git a/app/main/ipc/ai.ts b/app/main/ipc/ai.ts new file mode 100644 index 0000000..20b4db8 --- /dev/null +++ b/app/main/ipc/ai.ts @@ -0,0 +1,182 @@ +import { ipcMain } from "electron"; + +interface AiMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +interface AiPayload { + provider: "openrouter" | "openai" | "anthropic"; + apiKey: string; + model: string; + messages: AiMessage[]; + temperature?: number; + maxTokens?: number; +} + +async function fetchOpenRouter(apiKey: string, model: string, messages: AiMessage[], temperature: number, maxTokens: number) { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://codemotion.app", + "X-Title": "CodeMotion IDE" + }, + body: JSON.stringify({ + model, + messages, + temperature, + max_tokens: maxTokens, + include_reasoning: true, + transforms: ["middle-out"] + }) + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenRouter ${response.status}: ${err}`); + } + + return await response.json(); +} + +async function fetchOpenAI(apiKey: string, model: string, messages: AiMessage[], temperature: number, maxTokens: number) { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + model, + messages, + temperature, + max_tokens: maxTokens, + }) + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenAI ${response.status}: ${err}`); + } + + return await response.json(); +} + +async function fetchAnthropic(apiKey: string, model: string, messages: AiMessage[], temperature: number, maxTokens: number) { + const systemMsg = messages.find(m => m.role === "system")?.content || ""; + const otherMessages = messages.filter(m => m.role !== "system").map(m => ({ + role: m.role, + content: m.content + })); + + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": apiKey, + "Content-Type": "application/json", + "anthropic-version": "2023-06-01" + }, + body: JSON.stringify({ + model, + system: systemMsg, + messages: otherMessages, + temperature, + max_tokens: maxTokens, + }) + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Anthropic ${response.status}: ${err}`); + } + + return await response.json(); +} + +ipcMain.handle("ai-chat", async (_event, payload: AiPayload) => { + try { + let result: any; + const temp = payload.temperature ?? 0.7; + const maxTokens = payload.maxTokens ?? 4096; + + switch (payload.provider) { + case "openrouter": + result = await fetchOpenRouter(payload.apiKey, payload.model, payload.messages, temp, maxTokens); + break; + case "openai": + result = await fetchOpenAI(payload.apiKey, payload.model, payload.messages, temp, maxTokens); + break; + case "anthropic": + result = await fetchAnthropic(payload.apiKey, payload.model, payload.messages, temp, maxTokens); + break; + default: + throw new Error("Unknown provider"); + } + + let content = ""; + let reasoning = ""; + if (payload.provider === "anthropic") { + content = result.content?.[0]?.text || ""; + } else { + content = result.choices?.[0]?.message?.content || ""; + reasoning = result.choices?.[0]?.message?.reasoning || ""; + } + + // Prepend reasoning if present (OpenRouter models like Cohere North Mini Code) + if (reasoning && !content.startsWith(reasoning)) { + content = reasoning + (content ? "\n\n" + content : ""); + } + + return { + success: true, + content, + raw: result + }; + } catch (error: unknown) { + return { + success: false, + error: String(error) + }; + } +}); + +ipcMain.handle("ai-get-models", async () => { + return { + openrouter: [ + { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "openai/gpt-5.5", name: "GPT-5.5" }, + { id: "openai/gpt-5.5-pro", name: "GPT-5.5 Pro" }, + { id: "openai/gpt-5", name: "GPT-5" }, + { id: "openai/o4-mini", name: "o4-mini" }, + { id: "google/gemini-3.5-pro", name: "Gemini 3.5 Pro" }, + { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "google/gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "xai/grok-4.3", name: "Grok 4.3" }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, + { id: "meta-llama/llama-4", name: "Llama 4" }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "mistralai/mistral-large-3", name: "Mistral Large 3" }, + { id: "cohere/north-mini-code:free", name: "Cohere North Mini Code" }, + ], + openai: [ + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, + { id: "gpt-5", name: "GPT-5" }, + { id: "o4-mini", name: "o4-mini" }, + { id: "o3", name: "o3" }, + { id: "gpt-4o", name: "GPT-4o" }, + ], + anthropic: [ + { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" }, + ] + }; +}); diff --git a/app/main/ipc/aiAgent.ts b/app/main/ipc/aiAgent.ts new file mode 100644 index 0000000..fadc1e5 --- /dev/null +++ b/app/main/ipc/aiAgent.ts @@ -0,0 +1,318 @@ +import { ipcMain, IpcMainInvokeEvent } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +interface ToolResult { + success: boolean; + output: string; + error?: string; +} + +// Tool: read_file +async function toolReadFile(filePath: string): Promise { + try { + const content = fs.readFileSync(filePath, "utf-8"); + return { success: true, output: content }; + } catch (e: any) { + return { success: false, output: "", error: e.message }; + } +} + +// Tool: write_file +async function toolWriteFile(filePath: string, content: string): Promise { + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, content, "utf-8"); + return { success: true, output: `File written: ${filePath}` }; + } catch (e: any) { + return { success: false, output: "", error: e.message }; + } +} + +// Tool: edit_file (search and replace) +async function toolEditFile(filePath: string, oldString: string, newString: string): Promise { + try { + const content = fs.readFileSync(filePath, "utf-8"); + if (!content.includes(oldString)) { + return { success: false, output: "", error: "Search string not found in file" }; + } + const updated = content.replace(oldString, newString); + fs.writeFileSync(filePath, updated, "utf-8"); + return { success: true, output: `File edited: ${filePath}` }; + } catch (e: any) { + return { success: false, output: "", error: e.message }; + } +} + +// Tool: list_dir +async function toolListDir(dirPath: string): Promise { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const lines = entries.map(e => { + const type = e.isDirectory() ? "dir" : "file"; + return `${type}: ${e.name}`; + }); + return { success: true, output: lines.join("\n") }; + } catch (e: any) { + return { success: false, output: "", error: e.message }; + } +} + +// Tool: run_terminal +async function toolRunTerminal(cmd: string, cwd?: string): Promise { + try { + const { stdout, stderr } = await execAsync(cmd, { cwd, timeout: 30000 }); + return { success: true, output: stdout || stderr }; + } catch (e: any) { + return { success: false, output: e.stdout || "", error: e.stderr || e.message }; + } +} + +// Tool: search_code +async function toolSearchCode(rootPath: string, query: string): Promise { + try { + // Simple grep-like search + const { stdout } = await execAsync( + `powershell -Command "Get-ChildItem -Path '${rootPath}' -Recurse -File | Select-String -Pattern '${query}' | Select-Object -First 20"`, + { timeout: 15000 } + ); + return { success: true, output: stdout || "No matches found" }; + } catch (e: any) { + return { success: false, output: "", error: e.message }; + } +} + +// Tool dispatcher +ipcMain.handle("ai-agent-tool", async (_event: IpcMainInvokeEvent, toolName: string, args: any) => { + switch (toolName) { + case "read_file": + return toolReadFile(args.path); + case "write_file": + return toolWriteFile(args.path, args.content); + case "edit_file": + return toolEditFile(args.path, args.oldString, args.newString); + case "list_dir": + return toolListDir(args.path); + case "run_terminal": + return toolRunTerminal(args.command, args.cwd); + case "search_code": + return toolSearchCode(args.path, args.query); + default: + return { success: false, output: "", error: `Unknown tool: ${toolName}` }; + } +}); + +// Streaming AI Chat with tool support +interface AiMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + name?: string; + tool_calls?: any[]; +} + +const TOOLS_SCHEMA = [ + { + type: "function", + function: { + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path" } + }, + required: ["path"] + } + } + }, + { + type: "function", + function: { + name: "write_file", + description: "Create or overwrite a file with given content", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path" }, + content: { type: "string", description: "File content" } + }, + required: ["path", "content"] + } + } + }, + { + type: "function", + function: { + name: "edit_file", + description: "Edit a file by replacing oldString with newString", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path" }, + oldString: { type: "string", description: "Text to search for" }, + newString: { type: "string", description: "Replacement text" } + }, + required: ["path", "oldString", "newString"] + } + } + }, + { + type: "function", + function: { + name: "list_dir", + description: "List files and directories in a path", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute directory path" } + }, + required: ["path"] + } + } + }, + { + type: "function", + function: { + name: "run_terminal", + description: "Run a terminal command", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Command to run" }, + cwd: { type: "string", description: "Working directory" } + }, + required: ["command"] + } + } + }, + { + type: "function", + function: { + name: "search_code", + description: "Search for text pattern across files", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Root directory to search" }, + query: { type: "string", description: "Search pattern" } + }, + required: ["path", "query"] + } + } + } +]; + +const SYSTEM_PROMPT = `You are an AI coding agent. You help users write, edit, and understand code. + +You have access to tools to interact with the filesystem. Think step by step: +1. Understand the user's request +2. Use tools to gather information (read files, list directories) +3. Plan your changes +4. Execute changes using tools +5. Report what you did + +Always confirm destructive actions with the user before executing them. +When editing files, make precise changes using edit_file (search/replace). +When creating files, write the complete content. +`; + +ipcMain.handle("ai-agent-chat", async (_event, payload: { + provider: string; + apiKey: string; + model: string; + messages: AiMessage[]; + temperature?: number; + maxTokens?: number; + enableTools?: boolean; +}) => { + try { + const body: any = { + model: payload.model, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...payload.messages], + temperature: payload.temperature ?? 0.7, + max_tokens: payload.maxTokens ?? 8192, + }; + + if (payload.enableTools !== false) { + body.tools = TOOLS_SCHEMA; + body.tool_choice = "auto"; + } + + let url = ""; + let headers: any = { + "Content-Type": "application/json", + }; + + if (payload.provider === "openrouter") { + url = "https://openrouter.ai/api/v1/chat/completions"; + headers["Authorization"] = `Bearer ${payload.apiKey}`; + headers["HTTP-Referer"] = "https://codemotion.app"; + headers["X-Title"] = "CodeMotion IDE"; + body.include_reasoning = true; + body.transforms = ["middle-out"]; + } else if (payload.provider === "openai") { + url = "https://api.openai.com/v1/chat/completions"; + headers["Authorization"] = `Bearer ${payload.apiKey}`; + } else if (payload.provider === "anthropic") { + // Anthropic doesn't support tools the same way, fallback + url = "https://api.anthropic.com/v1/messages"; + headers["x-api-key"] = payload.apiKey; + headers["anthropic-version"] = "2023-06-01"; + const systemMsg = body.messages.find((m: any) => m.role === "system")?.content || ""; + const otherMessages = body.messages.filter((m: any) => m.role !== "system").map((m: any) => ({ + role: m.role, + content: m.content + })); + return { + success: true, + content: "Anthropic provider does not support agent tools in this version. Please use OpenRouter or OpenAI.", + tool_calls: [] + }; + } else { + throw new Error("Unknown provider"); + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`${response.status}: ${err}`); + } + + const result: any = await response.json(); + const choice = result.choices?.[0]; + const message = choice?.message; + + let content = message?.content || ""; + const tool_calls = message?.tool_calls || []; + const reasoning = message?.reasoning || ""; + + // Prepend reasoning if present + if (reasoning && !content.startsWith(reasoning)) { + content = reasoning + (content ? "\n\n" + content : ""); + } + + return { + success: true, + content, + tool_calls, + raw: result + }; + } catch (error: unknown) { + return { + success: false, + error: String(error) + }; + } +}); diff --git a/app/main/ipc/api.ts b/app/main/ipc/api.ts index 62fbf9a..6acbdeb 100644 --- a/app/main/ipc/api.ts +++ b/app/main/ipc/api.ts @@ -20,7 +20,7 @@ ipcMain.handle('get-user-data-from-api', async () => { if (!response.ok) { return { success: false, - result: result + result: (result as any)?.result || (result as any)?.message || "API request failed" } } diff --git a/app/main/ipc/filesWork.ts b/app/main/ipc/filesWork.ts index d008af9..1c493b5 100644 --- a/app/main/ipc/filesWork.ts +++ b/app/main/ipc/filesWork.ts @@ -7,9 +7,17 @@ import { readFileContent } from "../helpers/requests" import { SaveContentPayload } from "../payloads" import { APP_PATH } from "../helpers/paths" +function guardPath(targetPath: string): string { + const resolved = path.resolve(targetPath) + if (targetPath.includes('..') || !resolved.startsWith(path.resolve(process.cwd()))) { + throw new Error('Path traversal detected: path must stay within the working directory') + } + return resolved +} + ipcMain.handle("create-file", async (_: IpcMainInvokeEvent, targetPath: string) => { try { - const resolvedPath = path.resolve(targetPath) + const resolvedPath = guardPath(targetPath) const handle = await fs.promises.open(resolvedPath, "wx") await handle.close() return { success: true, path: resolvedPath } @@ -19,7 +27,7 @@ ipcMain.handle("create-file", async (_: IpcMainInvokeEvent, targetPath: string) }) ipcMain.handle("create-folder", async (_: IpcMainInvokeEvent, targetPath: string) => { try { - const resolvedPath = path.resolve(targetPath) + const resolvedPath = guardPath(targetPath) await fs.promises.mkdir(resolvedPath) return { success: true, path: resolvedPath } } catch (err: unknown) { @@ -88,7 +96,7 @@ ipcMain.handle("remove-by-path", async (_: IpcMainInvokeEvent, targetPath: strin throw new Error("Invalid path") } - const resolvedPath = path.resolve(targetPath) + const resolvedPath = guardPath(targetPath) if (!fs.existsSync(resolvedPath)) { return { success: false, error: "Path does not exist" } @@ -139,10 +147,13 @@ ipcMain.handle('ask-to-save-content', async (_: IpcMainInvokeEvent, payload: Sav ipcMain.handle("read-file", async (event: IpcMainInvokeEvent, filePath: string, parentPath: string): Promise<{ success: boolean; result: string | Error }> => { try { - const data = await fs.promises.readFile( - path.join(parentPath, filePath), - "utf-8" - ) + const target = path.resolve(path.join(parentPath, filePath)) + const resolvedParent = path.resolve(parentPath) + if (!target.startsWith(resolvedParent + path.sep)) { + throw new Error('Path traversal detected') + } + + const data = await fs.promises.readFile(target, "utf-8") return { success: true, diff --git a/app/main/ipc/getters.ts b/app/main/ipc/getters.ts index 76508d9..3f0a30d 100644 --- a/app/main/ipc/getters.ts +++ b/app/main/ipc/getters.ts @@ -28,9 +28,7 @@ ipcMain.handle("get-user-pc-info", async () => { arch: process.arch, cpus: os.cpus().length, totalMemory: os.totalmem(), - freeMemory: os.freemem(), - hostname: os.hostname(), - homedir: os.homedir() + freeMemory: os.freemem() }; }); ipcMain.handle('get-all-app-icons', () => { @@ -70,7 +68,7 @@ ipcMain.handle("get-all-languages-json", async () => { return await getAllLanguagesJSON() }) ipcMain.handle("get-app-icon", async () => { - return await getAppIcon() + return getAppIcon() }) ipcMain.handle("get-dirname", async () => { return __dirname diff --git a/app/main/ipc/misc.ts b/app/main/ipc/misc.ts index fd272fa..f8d10f9 100644 --- a/app/main/ipc/misc.ts +++ b/app/main/ipc/misc.ts @@ -1,5 +1,17 @@ import { ipcMain, IpcMainInvokeEvent, shell } from "electron"; +function isAllowedExternalUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + ipcMain.handle("open-in-browser", (_: IpcMainInvokeEvent, url: string) => { + if (!isAllowedExternalUrl(url)) { + throw new Error("Only http: and https: URLs are allowed"); + } shell.openExternal(url); }); \ No newline at end of file diff --git a/app/main/ipc/organizations.ts b/app/main/ipc/organizations.ts index 12b5829..08d8d53 100644 --- a/app/main/ipc/organizations.ts +++ b/app/main/ipc/organizations.ts @@ -30,7 +30,7 @@ ipcMain.handle('get-org-data-from-api', async (_: IpcMainInvokeEvent, orgID: num return { success: false, msg: data.result } } } catch (error) { - return { success: false, msg: error } + return { success: false, msg: error instanceof Error ? error.message : String(error) } } }) ipcMain.handle('remove-org', async (_: IpcMainInvokeEvent, orgID: number) => { @@ -55,7 +55,7 @@ ipcMain.handle('remove-org', async (_: IpcMainInvokeEvent, orgID: number) => { return { success: false, msg: data.result } } } catch (error) { - return { success: false, msg: error } + return { success: false, msg: error instanceof Error ? error.message : String(error) } } }) ipcMain.handle('join-org', async (_: IpcMainInvokeEvent, inviteCode: string) => { @@ -67,7 +67,8 @@ ipcMain.handle('join-org', async (_: IpcMainInvokeEvent, inviteCode: string) => const response = await fetch(`${API}/organizations/joinOrg.php`, { method: 'POST', headers: { - 'Authorization': `Bearer ${userToken}` + 'Authorization': `Bearer ${userToken}`, + 'Content-Type': 'application/json' }, body: JSON.stringify({ "invite_code": inviteCode @@ -82,6 +83,6 @@ ipcMain.handle('join-org', async (_: IpcMainInvokeEvent, inviteCode: string) => return { success: false, msg: data.result } } } catch (error) { - return { success: false, msg: error } + return { success: false, msg: error instanceof Error ? error.message : String(error) } } }) \ No newline at end of file diff --git a/app/main/main.ts b/app/main/main.ts index e396721..0b1daca 100644 --- a/app/main/main.ts +++ b/app/main/main.ts @@ -6,6 +6,7 @@ import fs from "node:fs" import { GlobalKeyboardListener } from "node-global-key-listener"; const v = new GlobalKeyboardListener(); +let keyboardListener: ((e: any, down: any) => void) | null = null; const bus = require("../../helpers/eventBus") const { verifyToken } = require("../auth") @@ -32,6 +33,8 @@ require("./ipc/updaters") require("./ipc/misc") require("./ipc/organizations") require("./ipc/bugs") +// require("./ipc/ai") +// require("./ipc/aiAgent") // ext require("../sandbox/regs/language") @@ -77,8 +80,7 @@ async function createWindow() { const localData = getLocalAppData(); const settingsData = getSettingsData() - const appIcon = await getAppIcon(); - const isPackaged = !app.isPackaged; + const appIcon = getAppIcon(); const primaryDisplay = screen.getPrimaryDisplay(); const { width, height } = primaryDisplay.workAreaSize; @@ -114,6 +116,10 @@ async function createWindow() { mainWindow.show(); }) mainWindow.on("closed", () => { + if (keyboardListener) { + v.removeListener(keyboardListener) + keyboardListener = null + } for (const win of notifications) { if (win && !win.isDestroyed()) win.close() } @@ -143,13 +149,14 @@ async function createWindow() { } } - v.addListener(function (e: any, down: any) { - if (mainWindow && mainWindow.isFocused() && e.state == "DOWN" && e.name == "S" && down["LEFT CTRL"]) { + keyboardListener = function (e: any, down: any) { + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused() && e.state == "DOWN" && e.name == "S" && down["LEFT CTRL"]) { mainWindow.webContents.send("keyboard_action", { type: "saved" }); } - }); + } + v.addListener(keyboardListener); }) .catch((err: TypeError) => { updateSplash(`Error: ${err.message}. Please report this error to the developer and try again later`, true) @@ -196,11 +203,6 @@ async function createWindow() { return true }) - // send app close. Example: close all notification windows - app.on('window-all-closed', () => { - bus.emit("main-closed", mainWindow); - }) - return { mainWindow, splash }; } @@ -213,6 +215,10 @@ app.whenReady().then(createWindow); app.on('before-quit', () => { terminalManager.killProcessTree(true); terminalManager.cleanupInputHandler(); + if (keyboardListener) { + v.removeListener(keyboardListener); + keyboardListener = null; + } }); setInterval(() => { @@ -220,10 +226,13 @@ setInterval(() => { }, 100) app.on('window-all-closed', () => { + bus.emit("main-closed", mainWindow); terminalManager.killProcessTree(true); terminalManager.cleanupInputHandler(); - - if (process.platform !== 'darwin') app.quit(); + if (keyboardListener) { + v.removeListener(keyboardListener); + keyboardListener = null; + } const settings = readSettings() @@ -236,4 +245,6 @@ app.on('window-all-closed', () => { writeSettings({ app: { workSecondsSession: Math.round(workSeconds * 10) / 10 }}) } } + + if (process.platform !== 'darwin') app.quit(); }); diff --git a/app/main/preload.ts b/app/main/preload.ts index a6b8841..b0562eb 100644 --- a/app/main/preload.ts +++ b/app/main/preload.ts @@ -202,4 +202,10 @@ contextBridge.exposeInMainWorld('electron', { on: (event: any, msg: any) => ipcRenderer.on(event, msg), oncb: (event: any, cb: any) => ipcRenderer.on(event, (_: any, data: any) => cb(data)), + + // aiChat: (payload: any) => ipcRenderer.invoke("ai-chat", payload), + // aiGetModels: () => ipcRenderer.invoke("ai-get-models"), + + // aiAgentTool: (toolName: string, args: any) => ipcRenderer.invoke("ai-agent-tool", toolName, args), + // aiAgentChat: (payload: any) => ipcRenderer.invoke("ai-agent-chat", payload), }); diff --git a/app/main/runtime/runtimeHandler.ts b/app/main/runtime/runtimeHandler.ts index 82f3d2d..500227c 100644 --- a/app/main/runtime/runtimeHandler.ts +++ b/app/main/runtime/runtimeHandler.ts @@ -4,6 +4,46 @@ import fs from "fs" import path from "node:path" import { RunPythonPayload } from "../payloads" +const BLOCKED_PYTHON_PATTERNS = [ + /\b__import__\b/, + /\bimport\s+os\b/, + /\bimport\s+subprocess\b/, + /\bimport\s+sys\b/, + /\bimport\s+socket\b/, + /\bimport\s+urllib\b/, + /\bimport\s+ftplib\b/, + /\bimport\s+shutil\b/, + /\bimport\s+pty\b/, + /\bimport\s+multiprocessing\b/, + /\bimport\s+threading\b/, + /\bfrom\s+os\b/, + /\bfrom\s+subprocess\b/, + /\bfrom\s+sys\b/, + /\bfrom\s+socket\b/, + /\bfrom\s+urllib\b/, + /\bfrom\s+ftplib\b/, + /\bfrom\s+shutil\b/, + /\bopen\s*\(/, + /\bexec\s*\(/, + /\beval\s*\(/, + /\bcompile\s*\(/, + /\binput\s*\(/, + /\bgetattr\s*\(/, + /\bsetattr\s*\(/, + /\bdelattr\s*\(/, +] + +function validatePythonCode(code: string): string | null { + if (typeof code !== "string") return "Python code must be a string" + if (code.length > 50000) return "Python code exceeds maximum length of 50000 characters" + for (const pattern of BLOCKED_PYTHON_PATTERNS) { + if (pattern.test(code)) { + return `Forbidden Python pattern detected: ${pattern.source}` + } + } + return null +} + type RunPythonResult = | { type: "file_not_found" | "no_input" | "python_not_found" | "timeout" | "spawn_error" | "internal_error" @@ -85,6 +125,14 @@ ipcMain.handle( runPath = filePath } else if (code) { + const validationError = validatePythonCode(code) + if (validationError) { + return finish({ + type: "internal_error", + result: validationError + }) + } + if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }) } @@ -139,7 +187,7 @@ ipcMain.handle( let stderr = "" const timeout = setTimeout(() => { - py!.kill() + py?.kill() finish({ type: "timeout", diff --git a/app/main/tools/diagnostics.ts b/app/main/tools/diagnostics.ts index 12e743b..14615ed 100644 --- a/app/main/tools/diagnostics.ts +++ b/app/main/tools/diagnostics.ts @@ -4,18 +4,23 @@ import path from "path" type DiagnosticResult = unknown -type PendingResolver = ((value: DiagnosticResult) => void) | null - type WorkerMap = { js: Worker ts: Worker } +interface PendingEntry { + id: number + resolve: (value: DiagnosticResult) => void +} + type PendingMap = { - js: PendingResolver - ts: PendingResolver + js: Map + ts: Map } +let nextId = 1 + function createWorker(filename: string): Worker { const worker = new Worker(path.join(__dirname, filename)) @@ -38,21 +43,23 @@ const workers: WorkerMap = { } const pending: PendingMap = { - js: null, - ts: null, + js: new Map(), + ts: new Map(), } -workers.js.on("message", (diagnostics: DiagnosticResult) => { - if (pending.js) { - pending.js(diagnostics) - pending.js = null +workers.js.on("message", (msg: { id?: number; diagnostics: DiagnosticResult }) => { + const entry = pending.js.get(msg.id ?? 0) + if (entry) { + entry.resolve(msg.diagnostics) + pending.js.delete(msg.id ?? 0) } }) -workers.ts.on("message", (diagnostics: DiagnosticResult) => { - if (pending.ts) { - pending.ts(diagnostics) - pending.ts = null +workers.ts.on("message", (msg: { id?: number; diagnostics: DiagnosticResult }) => { + const entry = pending.ts.get(msg.id ?? 0) + if (entry) { + entry.resolve(msg.diagnostics) + pending.ts.delete(msg.id ?? 0) } }) @@ -65,14 +72,16 @@ workers.ts.on("error", (err) => { ipcMain.handle("javascript-diagnostic", (_event: IpcMainInvokeEvent, code: string): Promise => { return new Promise((resolve) => { - pending.js = resolve - workers.js.postMessage(code) + const id = nextId++ + pending.js.set(id, { id, resolve }) + workers.js.postMessage({ id, code }) }) }) ipcMain.handle("typescript-diagnostic", (_event: IpcMainInvokeEvent, code: string): Promise => { return new Promise((resolve) => { - pending.ts = resolve - workers.ts.postMessage(code) + const id = nextId++ + pending.ts.set(id, { id, resolve }) + workers.ts.postMessage({ id, code }) }) }) \ No newline at end of file diff --git a/app/main/tools/javascript/diagnosticsJsWorker.js b/app/main/tools/javascript/diagnosticsJsWorker.js index a2d390c..7db49eb 100644 --- a/app/main/tools/javascript/diagnosticsJsWorker.js +++ b/app/main/tools/javascript/diagnosticsJsWorker.js @@ -92,12 +92,14 @@ function guessLength(code, start) { return Math.max(1, end - start); } -parentPort.on("message", (code) => { +parentPort.on("message", (msg) => { + const code = typeof msg === "object" && msg !== null ? msg.code : msg; + const id = typeof msg === "object" && msg !== null ? msg.id : undefined; try { const diagnostics = getDiagnostics(code); - parentPort.postMessage(diagnostics); + parentPort.postMessage({ id, diagnostics }); } catch (e) { console.error("diagnosticsJsWorker error:", e); - parentPort.postMessage([]); + parentPort.postMessage({ id, diagnostics: [] }); } }); \ No newline at end of file diff --git a/app/main/tools/typescript/diagnosticsTsWorker.js b/app/main/tools/typescript/diagnosticsTsWorker.js index 59a9c01..0c49b85 100644 --- a/app/main/tools/typescript/diagnosticsTsWorker.js +++ b/app/main/tools/typescript/diagnosticsTsWorker.js @@ -89,12 +89,14 @@ function guessLength(code, start) { return Math.max(1, end - start); } -parentPort.on("message", (code) => { +parentPort.on("message", (msg) => { + const code = typeof msg === "object" && msg !== null ? msg.code : msg; + const id = typeof msg === "object" && msg !== null ? msg.id : undefined; try { const diagnostics = getDiagnostics(code); - parentPort.postMessage(diagnostics); + parentPort.postMessage({ id, diagnostics }); } catch (e) { console.error("diagnosticsTsWorker error:", e); - parentPort.postMessage([]); + parentPort.postMessage({ id, diagnostics: [] }); } }); \ No newline at end of file diff --git a/app/notifications/notifications.js b/app/notifications/notifications.js index 72a79de..fecae67 100644 --- a/app/notifications/notifications.js +++ b/app/notifications/notifications.js @@ -40,8 +40,6 @@ function closeNotification(win) { } function spawnNotification(properties = {}) { - ipcMain.removeAllListeners("notification-close") - const timeout = properties.timeout ?? 4000 const win = new BrowserWindow({ @@ -87,7 +85,15 @@ function spawnNotification(properties = {}) { }, 50) }) - win.on("closed", () => { + const closeHandler = (event) => { + const senderWin = BrowserWindow.fromWebContents(event.sender) + if (senderWin === win && !win.isDestroyed()) win.close() + } + + ipcMain.on("notification-close", closeHandler) + + win.once("closed", () => { + ipcMain.removeListener("notification-close", closeHandler) const i = notifications.indexOf(win) if (i !== -1) notifications.splice(i, 1) @@ -102,11 +108,6 @@ function spawnNotification(properties = {}) { }, timeout) } - ipcMain.on("notification-close", (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (win && !win.isDestroyed()) win.close() - }) - return win } diff --git a/app/notifications/renderer.js b/app/notifications/renderer.js index 7a3f65a..ed52ced 100644 --- a/app/notifications/renderer.js +++ b/app/notifications/renderer.js @@ -17,7 +17,7 @@ window.electron.onData(data => { const notifyClose = document.querySelector(".notification-close") if(!icon) { - if(image) { + if(image && /^https?:\/\//.test(String(image))) { const img = document.createElement("img") img.classList.add("notification-image") img.src = image diff --git a/app/renderer.js b/app/renderer.js index 02e583c..4bea079 100644 --- a/app/renderer.js +++ b/app/renderer.js @@ -43,6 +43,7 @@ import { setupSegmentedControl } from "../assets/js/handlers/segmentedControlHan import { getAddBugModal } from "../assets/js/modals/addBugModal.js" import { getLogoutModal } from "../assets/js/modals/logoutModal.js" import { ExplorerSidebar } from "../assets/js/sidebar/ExplorerSidebar.js" +// import { initAiPanel } from "../assets/js/aiPanel.js" let isSaveAviable = true @@ -270,6 +271,27 @@ document.addEventListener("DOMContentLoaded", async () => { if (tab.hasAttribute("default")) tab.click(); }); + // // AI Panel (temporarily disabled for debugging) + // initAiPanel( + // () => { + // if (currentPath && tabsByPath.has(currentPath)) { + // return tabsByPath.get(currentPath).editor.getValue(); + // } + // return ""; + // }, + // () => currentPath || "", + // gls + // ); + + // document.addEventListener("ai-request-insert", (e) => { + // if (currentPath && tabsByPath.has(currentPath)) { + // const editor = tabsByPath.get(currentPath).editor; + // const session = editor.getSession(); + // const cursor = editor.getCursorPosition(); + // session.insert(cursor, e.detail); + // } + // }); + // File panel // Keybinds diff --git a/app/sandbox/permissions/audio/play.js b/app/sandbox/permissions/audio/play.js index 1b75d40..8e817a8 100644 --- a/app/sandbox/permissions/audio/play.js +++ b/app/sandbox/permissions/audio/play.js @@ -1,6 +1,4 @@ -const { ipcMain } = require("electron") -const { getExt, isFileExists, createSandboxConsole } = require("../../tools") -const path = require("path") +const { getExt, isFileExists, createSandboxConsole, resolveSandboxPath } = require("../../tools") function callback(data) { const audioFilePath = data.selfArgs[0] @@ -32,7 +30,7 @@ function callback(data) { if (speed < 0.5) speed = 1 if(aviableExts.includes(fileExt)) { - const fullAudioPath = path.join(extPath, audioFilePath) + const fullAudioPath = resolveSandboxPath(extPath, audioFilePath) const isAudioFound = isFileExists(fullAudioPath) if(!isAudioFound) { diff --git a/app/sandbox/permissions/commands/registerCommand.js b/app/sandbox/permissions/commands/registerCommand.js index 78a85bb..ae729fe 100644 --- a/app/sandbox/permissions/commands/registerCommand.js +++ b/app/sandbox/permissions/commands/registerCommand.js @@ -10,7 +10,7 @@ function callback(data) { }) if (/\s/g.test(input.name)) { - throw new Error(`The command cannot contain spaces. Use characters such as "-", "_", etc., instead. Example: ${data.name.replaceAll(/\s/g, "-")}`) + throw new Error(`The command cannot contain spaces. Use characters such as "-", "_", etc., instead. Example: ${input.name.replaceAll(/\s/g, "-")}`) } if (input.name.startsWith("-")) { throw new Error(`A command name cannot begin with a hyphen (-) when registering a command, because commands that start with this character may be reserved by the program`) diff --git a/app/sandbox/permissions/css/load.js b/app/sandbox/permissions/css/load.js index 19f871f..850e168 100644 --- a/app/sandbox/permissions/css/load.js +++ b/app/sandbox/permissions/css/load.js @@ -1,11 +1,10 @@ -const { saveReadFile } = require("../../tools.js") -const path = require("path") +const { saveReadFile, resolveSandboxPath } = require("../../tools.js") function callback(data) { const extName = data.extensionName const extPath = data.extensionPath const filename = data.selfArgs[0] + ".css" - const CSSContent = saveReadFile(path.join(extPath, filename)) + const CSSContent = saveReadFile(resolveSandboxPath(extPath, filename)) if (!CSSContent) throw new Error(`The file "${filename}" was not found or is empty`) diff --git a/app/sandbox/permissions/editor/dirs/newIconSet.js b/app/sandbox/permissions/editor/dirs/newIconSet.js index dec7c16..c1dabad 100644 --- a/app/sandbox/permissions/editor/dirs/newIconSet.js +++ b/app/sandbox/permissions/editor/dirs/newIconSet.js @@ -1,16 +1,15 @@ -const { saveReadFile } = require("../../../tools.js") -const path = require("path") +const { saveReadFile, resolveSandboxPath } = require("../../../tools.js") function callback(data) { const extPath = data.extensionPath const configPath = data.selfArgs[0] if (configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json"), true) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json"), true) configContent = JSON.parse(configContent) Object.keys(configContent).forEach(k => { - configContent[k] = path.join(extPath, configContent[k]) + configContent[k] = resolveSandboxPath(extPath, configContent[k]) }) data.mainSender.send("new-dir-icon-register", configContent) diff --git a/app/sandbox/permissions/editor/docs/register.js b/app/sandbox/permissions/editor/docs/register.js index 8155f36..7ee7305 100644 --- a/app/sandbox/permissions/editor/docs/register.js +++ b/app/sandbox/permissions/editor/docs/register.js @@ -1,5 +1,4 @@ -const { checkFields, saveReadFile } = require("../../../tools") -const path = require("path") +const { checkFields, saveReadFile, resolveSandboxPath } = require("../../../tools") function callback(data) { const configPath = data.selfArgs[0] @@ -8,7 +7,7 @@ function callback(data) { let documentationProperties = {} if (configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json"), true) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json"), true) configContent = JSON.parse(configContent) const docPropertiesKey = "__$props__" diff --git a/app/sandbox/permissions/editor/language/registerIcons.js b/app/sandbox/permissions/editor/language/registerIcons.js index 77fa77b..c7cf5c8 100644 --- a/app/sandbox/permissions/editor/language/registerIcons.js +++ b/app/sandbox/permissions/editor/language/registerIcons.js @@ -1,16 +1,15 @@ -const { saveReadFile, log } = require("../../../tools.js") -const path = require("path") +const { saveReadFile, log, resolveSandboxPath } = require("../../../tools.js") function callback(data) { const extPath = data.extensionPath const configPath = data.selfArgs[0] if (configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json"), true) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json"), true) configContent = JSON.parse(configContent) Object.keys(configContent).forEach(k => { - configContent[k] = path.join(extPath, configContent[k].icon) + configContent[k] = resolveSandboxPath(extPath, configContent[k].icon) }) data.mainSender.send("new-language-icons-register", configContent) diff --git a/app/sandbox/permissions/events/onFileOpened.js b/app/sandbox/permissions/events/onFileOpened.js index 2178c4e..0f26283 100644 --- a/app/sandbox/permissions/events/onFileOpened.js +++ b/app/sandbox/permissions/events/onFileOpened.js @@ -1,11 +1,21 @@ const { ipcMain } = require("electron") +const fileOpenedHandlers = new Map() + function callback(data) { const cb = data.selfArgs[0] + const extName = data.extensionName + + const oldHandler = fileOpenedHandlers.get(extName) + if (oldHandler) { + ipcMain.removeListener("file-opened-event", oldHandler) + } - ipcMain.on("file-opened-event", (_, data) => { - cb(data) - }) + const handler = (_, eventData) => { + cb(eventData) + } + fileOpenedHandlers.set(extName, handler) + ipcMain.on("file-opened-event", handler) } module.exports = { callback } \ No newline at end of file diff --git a/app/sandbox/permissions/http/request.js b/app/sandbox/permissions/http/request.js index f25400a..a642ccb 100644 --- a/app/sandbox/permissions/http/request.js +++ b/app/sandbox/permissions/http/request.js @@ -1,3 +1,25 @@ +function isPrivateUrl(urlString) { + try { + const url = new URL(urlString) + const hostname = url.hostname.toLowerCase() + if (url.protocol !== 'http:' && url.protocol !== 'https:') return true + if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true + if (hostname === '127.0.0.1' || hostname.startsWith('127.')) return true + if (hostname.startsWith('10.')) return true + if (hostname.startsWith('172.')) { + const second = parseInt(hostname.split('.')[1], 10) + if (second >= 16 && second <= 31) return true + } + if (hostname.startsWith('192.168.')) return true + if (hostname.startsWith('169.254.')) return true + if (hostname.startsWith('fc00:') || hostname.startsWith('fe80:')) return true + if (hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]') return true + return false + } catch { + return true + } +} + async function callback(data) { const properties = data.selfArgs[0] const url = properties.url @@ -6,6 +28,9 @@ async function callback(data) { const body = properties.body try { + if (isPrivateUrl(url)) { + throw new Error('Access to private/internal addresses is blocked') + } const options = { method, headers diff --git a/app/sandbox/permissions/localization/register.js b/app/sandbox/permissions/localization/register.js index fe0e1de..d606498 100644 --- a/app/sandbox/permissions/localization/register.js +++ b/app/sandbox/permissions/localization/register.js @@ -1,5 +1,4 @@ -const path = require("node:path") -const { saveReadFile, createSandboxConsole, checkFields } = require("../../tools.js") +const { saveReadFile, createSandboxConsole, checkFields, resolveSandboxPath } = require("../../tools.js") function callback(data) { const langName = data.selfArgs[0] @@ -18,7 +17,7 @@ function callback(data) { } if(configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json")) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json")) configContent = JSON.parse(configContent) if(!configContent) { diff --git a/app/sandbox/permissions/window/close.js b/app/sandbox/permissions/window/close.js index ef80c73..4181bb1 100644 --- a/app/sandbox/permissions/window/close.js +++ b/app/sandbox/permissions/window/close.js @@ -1,7 +1,18 @@ -const { app } = require("electron"); +const { app, dialog, BrowserWindow } = require("electron"); function callback(data) { - app.quit(); + const mainWindow = BrowserWindow.getAllWindows().find(w => w.title && !w.title.includes("Debugger")); + const choice = dialog.showMessageBoxSync(mainWindow || undefined, { + type: "warning", + buttons: ["Quit", "Cancel"], + defaultId: 1, + title: "Quit Application", + message: `Extension "${data.extensionName}" wants to quit the application.`, + detail: "Any unsaved work will be lost. Are you sure?" + }); + if (choice === 0) { + app.quit(); + } } module.exports = { callback } \ No newline at end of file diff --git a/app/sandbox/permissions/window/create.js b/app/sandbox/permissions/window/create.js index e4e0fe1..7a78813 100644 --- a/app/sandbox/permissions/window/create.js +++ b/app/sandbox/permissions/window/create.js @@ -1,6 +1,13 @@ const { createNativeImageFromUrl } = require("../../tools.js") const { BrowserWindow } = require("electron") +function isValidExtensionUrl(urlStr) { + if (typeof urlStr !== 'string') return false + if (urlStr.includes('@') || urlStr.includes('#') || urlStr.includes('?') || urlStr.includes('/')) return false + if (!/^[a-zA-Z0-9][a-zA-Z0-9\-.]+$/.test(urlStr)) return false + return true +} + function callback(data) { return (id, properties = {}) => { if (id == undefined) { @@ -8,19 +15,26 @@ function callback(data) { } const title = properties.title == undefined ? `${data.extensionName} Window` : properties.title - const url = properties.url == undefined ? `google.com` : properties.url + const rawUrl = properties.url == undefined ? `google.com` : properties.url + + if (!isValidExtensionUrl(rawUrl)) { + throw new Error(`Invalid URL provided for extension window: "${rawUrl}"`) + } - const win = new BrowserWindow( - { - width: 800, - height: 600, - show: false + const win = new BrowserWindow({ + width: 800, + height: 600, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true } - ) + }) win.setMenu(null) win.setTitle(title) - win.loadURL(`https://${url}`) + win.loadURL(`https://${rawUrl}`) return { id: id, diff --git a/app/sandbox/regs/docs.js b/app/sandbox/regs/docs.js index f151288..956719f 100644 --- a/app/sandbox/regs/docs.js +++ b/app/sandbox/regs/docs.js @@ -1,5 +1,5 @@ const { ipcMain } = require("electron") -const { checkFields, saveReadFile } = require("../tools") +const { checkFields, saveReadFile, resolveSandboxPath } = require("../tools") const path = require("path") const bus = require("../../../helpers/eventBus") @@ -22,7 +22,7 @@ ipcMain.on("docs-register", async (event, data) => { let documentationProperties = {} if (configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json"), true) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json"), true) configContent = JSON.parse(configContent) const docPropertiesKey = "__$props__" diff --git a/app/sandbox/regs/language.js b/app/sandbox/regs/language.js index dd37472..65d8d4a 100644 --- a/app/sandbox/regs/language.js +++ b/app/sandbox/regs/language.js @@ -1,5 +1,5 @@ const { app, ipcMain } = require("electron") -const { checkFields, saveReadFile, isFileExists } = require("../tools") +const { checkFields, saveReadFile, isFileExists, resolveSandboxPath } = require("../tools") const path = require("path") const bus = require("../../../helpers/eventBus") @@ -19,7 +19,7 @@ ipcMain.on("language-register", async (event, data) => { const extName = data.extensionName if (configPath) { - let configContent = saveReadFile(path.join(extPath, configPath + ".json"), true) + let configContent = saveReadFile(resolveSandboxPath(extPath, configPath + ".json"), true) configContent = JSON.parse(configContent) checkFields(`language.register:config`, configContent, { @@ -29,7 +29,7 @@ ipcMain.on("language-register", async (event, data) => { rules: "string" }) - let rulesConfig = saveReadFile(path.join(extPath, configContent.rules + ".json"), true) + let rulesConfig = saveReadFile(resolveSandboxPath(extPath, configContent.rules + ".json"), true) rulesConfig = JSON.parse(rulesConfig) checkFields(`language.register:config:rules`, rulesConfig, { @@ -45,7 +45,7 @@ ipcMain.on("language-register", async (event, data) => { icon: "SVGFile|PNGFile" }) - iconPath = path.join(extPath, configContent.icon) + iconPath = resolveSandboxPath(extPath, configContent.icon) isFileExists(iconPath, true) } else { @@ -70,7 +70,7 @@ ipcMain.on("language-register", async (event, data) => { } if ("documentation" in configContent) { - let documentationConfig = saveReadFile(path.join(extPath, configContent.documentation + ".json"), true) + let documentationConfig = saveReadFile(resolveSandboxPath(extPath, configContent.documentation + ".json"), true) documentationConfig = JSON.parse(documentationConfig) dataToSend["languageDocumentation"] = documentationConfig diff --git a/app/sandbox/sandbox.js b/app/sandbox/sandbox.js index fb2e540..9891873 100644 --- a/app/sandbox/sandbox.js +++ b/app/sandbox/sandbox.js @@ -192,49 +192,67 @@ ipcMain.handle("run-extension", async (event, code, permissions, meta) => { } }) - return Object.freeze(app); + function deepFreeze(obj) { + Object.keys(obj).forEach(key => { + const val = obj[key] + if (val && typeof val === "object") { + deepFreeze(val) + } + }) + return Object.freeze(obj) + } + + return deepFreeze(app); } try { let app = createAPI(permissions); + if (typeof code !== "string") { + return { success: false, error: "Extension code must be a string" } + } + + if (code.includes('`')) { + return { success: false, error: "Back-tick characters are not allowed in extension code" } + } + const sandbox = { console: createSandboxConsole(extensionName, debuggerSender), - Map: Map, app }; const context = vm.createContext(sandbox); - await vm.runInContext(` - (async function(){ - "use strict"; - ${code} - })() - `, context); + const script = new vm.Script(`(async function(){"use strict";${code}})()`); + await script.runInContext(context, { timeout: 5000 }); return { success: true }; } catch (err) { const stack = err?.stack || String(err) const evalLocation = stack.match(/evalmachine\.:(\d+):(\d+)/) + let cleanStack = stack + .split('\n') + .filter(line => !line.includes('evalmachine.')) + .join('\n') + if (!evalLocation) { return { success: false, - error: `\n${err?.message || stack}` + error: `\n${err?.message || cleanStack}` }; } const lineNumber = Number(evalLocation[1]) const columnNumber = Number(evalLocation[2]) - let message = stack.replaceAll(evalLocation[0], "").split("at")[0].trim() + let message = cleanStack.replaceAll(evalLocation[0], "").split("at")[0].trim() message += `\n\tat line: ${lineNumber - 3}` message += `\n\tat column: ${columnNumber}` - return { + return { success: false, - error: String(err) + error: message }; } }); \ No newline at end of file diff --git a/app/sandbox/tools.js b/app/sandbox/tools.js index a0bec10..cde6a71 100644 --- a/app/sandbox/tools.js +++ b/app/sandbox/tools.js @@ -175,12 +175,22 @@ function getExt(filename) { return ext } -module.exports = { - createNativeImageFromUrl, - getType, - checkType, - ok, - fail, +function resolveSandboxPath(extPath, relativePath) { + const resolved = path.resolve(path.join(extPath, relativePath)) + const resolvedExt = path.resolve(extPath) + const separator = path.sep + if (!resolved.startsWith(resolvedExt + separator)) { + throw new Error(`Path traversal detected: "${relativePath}" resolves outside extension directory`) + } + return resolved +} + +module.exports = { + createNativeImageFromUrl, + getType, + checkType, + ok, + fail, isSafeName, stringify, saveReadFile, @@ -189,5 +199,6 @@ module.exports = { createSandboxConsole, getArgumentNames, log, - getExt + getExt, + resolveSandboxPath } \ No newline at end of file diff --git a/app/splash/splash-preload.js b/app/splash/splash-preload.js new file mode 100644 index 0000000..bbd11de --- /dev/null +++ b/app/splash/splash-preload.js @@ -0,0 +1,8 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electron', { + close: () => ipcRenderer.send("close"), + setNonAccountMode: (value) => ipcRenderer.invoke("set-non-account-mode", value), + reload: () => ipcRenderer.send("reload"), + onStatusUpdate: (callback) => ipcRenderer.on("status-update", (_, data) => callback(data)) +}); diff --git a/app/splash/splash.js b/app/splash/splash.js index 5b2e872..e2bd744 100644 --- a/app/splash/splash.js +++ b/app/splash/splash.js @@ -1,11 +1,12 @@ const { BrowserWindow, app } = require("electron") -const { PRELOAD_PATH, SPLASH_HTML_PATH } = require("../main/helpers/paths.js") +const { SPLASH_HTML_PATH } = require("../main/helpers/paths.js") const { getAppIcon } = require("../main/helpers/requests.js") +const path = require("path") let splash; async function createSplashWindow() { - const appIcon = await getAppIcon() + const appIcon = getAppIcon() splash = new BrowserWindow({ width: 800, @@ -17,7 +18,7 @@ async function createSplashWindow() { center: true, show: true, webPreferences: { - preload: PRELOAD_PATH + preload: path.join(__dirname, "splash-preload.js") }, icon: appIcon }); @@ -28,7 +29,7 @@ async function createSplashWindow() { } function updateSplash(text, isError = false) { - if(splash) { + if(splash && !splash.isDestroyed()) { splash.webContents.send("status-update", { msg: text, error: isError }); } } diff --git a/assets/css/components/ai-panel.css b/assets/css/components/ai-panel.css new file mode 100644 index 0000000..545279c --- /dev/null +++ b/assets/css/components/ai-panel.css @@ -0,0 +1,536 @@ +.ai-panel { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px; + gap: 10px; + overflow: hidden; +} + +.ai-panel__setup, +.ai-panel__chat { + display: flex; + flex-direction: column; + height: 100%; + gap: 10px; +} + +.ai-panel__setup.hidden, +.ai-panel__chat.hidden { + display: none; +} + +.ai-panel__header { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + padding-bottom: 8px; + border-bottom: 1px solid var(--block-divider-border-color); + flex-shrink: 0; +} + +.ai-panel__settings-btn, +.ai-panel__back-btn { + opacity: 0.5; + cursor: pointer; + font-size: 18px; + transition: opacity 0.2s; +} + +.ai-panel__settings-btn { + margin-left: auto; +} + +.ai-panel__settings-btn:hover, +.ai-panel__back-btn:hover { + opacity: 1; +} + +.ai-panel__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ai-panel__field label { + font-size: 12px; + color: var(--text-color-muted); +} + +.ai-panel__field input, +.ai-panel__field select { + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + padding: 8px 10px; + color: var(--text-color); + font-size: 13px; + outline: none; +} + +.ai-panel__field input:focus, +.ai-panel__field select:focus { + border-color: var(--topbar-menu-item-hover-bg); +} + +.ai-panel__btn { + background: var(--topbar-menu-item-hover-bg); + border: none; + border-radius: 6px; + padding: 10px; + color: var(--text-color); + font-size: 13px; + cursor: pointer; + transition: opacity 0.2s; + margin-top: auto; +} + +.ai-panel__btn:hover { + opacity: 0.8; +} + +.ai-panel__messages { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 4px; +} + +.ai-message { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + font-size: 13px; + line-height: 1.5; + max-width: 100%; + word-break: break-word; +} + +.ai-message.user { + background: var(--topbar-menu-item-hover-bg); + align-self: flex-end; +} + +.ai-message.assistant { + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + align-self: flex-start; +} + +.ai-message .ai-message__role { + font-size: 11px; + opacity: 0.5; + font-weight: 600; +} + +.ai-message pre { + background: var(--code-background, #0e0e0e); + padding: 8px; + border-radius: 6px; + overflow-x: auto; + font-family: monospace; + font-size: 12px; + margin: 4px 0; +} + +.ai-message code { + font-family: monospace; + background: var(--code-background, #0e0e0e); + padding: 2px 4px; + border-radius: 3px; + font-size: 12px; +} + +.ai-message .ai-message__actions { + display: flex; + gap: 6px; + margin-top: 4px; +} + +.ai-message__actions button { + background: transparent; + border: 1px solid var(--block-divider-border-color); + border-radius: 4px; + padding: 4px 8px; + color: var(--text-color-muted); + font-size: 11px; + cursor: pointer; + transition: 0.2s; +} + +.ai-message__actions button:hover { + background: var(--topbar-menu-item-hover-bg); + color: var(--text-color); +} + +.ai-panel__quick-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex-shrink: 0; +} + +.ai-panel__quick-actions button { + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 5px; + padding: 5px 10px; + color: var(--text-color-muted); + font-size: 12px; + cursor: pointer; + transition: 0.2s; +} + +.ai-panel__quick-actions button:hover { + background: var(--topbar-menu-item-hover-bg); + color: var(--text-color); +} + +.ai-panel__input-row { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: flex-end; +} + +.ai-panel__input-row textarea { + flex: 1; + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 8px; + padding: 8px 10px; + color: var(--text-color); + font-size: 13px; + outline: none; + resize: none; + min-height: 36px; + max-height: 120px; + font-family: inherit; +} + +.ai-panel__input-row textarea:focus { + border-color: var(--topbar-menu-item-hover-bg); +} + +.ai-panel__send-btn { + background: var(--topbar-menu-item-hover-bg); + border: none; + border-radius: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-color); + cursor: pointer; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.ai-panel__send-btn:hover { + opacity: 0.8; +} + +.ai-panel__send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ai-typing { + display: flex; + gap: 4px; + padding: 8px 10px; + align-self: flex-start; +} + +.ai-typing span { + width: 6px; + height: 6px; + background: var(--text-color-muted); + border-radius: 50%; + animation: aiTypingBounce 1.4s infinite ease-in-out both; +} + +.ai-typing span:nth-child(1) { animation-delay: -0.32s; } +.ai-typing span:nth-child(2) { animation-delay: -0.16s; } + +@keyframes aiTypingBounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } +} + +.ai-panel__error { + color: #ff5a5a; + font-size: 12px; + padding: 4px 0; +} + +/* Attachments (collapsible spoilers) */ +.ai-attachment { + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + margin-top: 6px; + overflow: hidden; +} + +.ai-attachment__header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--body-color-solid); + cursor: pointer; + font-size: 12px; + color: var(--text-color-muted); + user-select: none; +} + +.ai-attachment__header:hover { + background: var(--block-divider-border-color); +} + +.ai-attachment__icon { + font-size: 14px; +} + +.ai-attachment__filename { + flex: 1; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-attachment__toggle { + font-size: 16px; + transition: transform 0.2s; +} + +.ai-attachment.expanded .ai-attachment__toggle { + transform: rotate(180deg); +} + +.ai-attachment__content { + display: none; + padding: 8px 10px; + background: var(--code-background, #0e0e0e); + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + color: var(--text-color); +} + +.ai-attachment.expanded .ai-attachment__content { + display: block; +} + +/* Agent Mode */ +.ai-mode-toggle { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.ai-mode-btn { + flex: 1; + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 5px; + padding: 5px 10px; + color: var(--text-color-muted); + font-size: 12px; + cursor: pointer; + transition: 0.2s; +} + +.ai-mode-btn.active { + background: var(--topbar-menu-item-hover-bg); + color: var(--text-color); +} + +.ai-mode-btn:hover:not(.active) { + background: var(--block-divider-border-color); +} + +.ai-mode-content { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + overflow: hidden; +} + +.ai-mode-content.hidden { + display: none; +} + +.ai-agent__task-input-row { + display: flex; + gap: 6px; + flex-shrink: 0; + align-items: flex-end; +} + +.ai-agent__task-input-row textarea { + flex: 1; + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 8px; + padding: 8px 10px; + color: var(--text-color); + font-size: 13px; + outline: none; + resize: none; + min-height: 44px; + max-height: 80px; + font-family: inherit; +} + +.ai-agent__task-input-row textarea:focus { + border-color: var(--topbar-menu-item-hover-bg); +} + +.ai-agent__status { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--body-color-solid); + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + font-size: 12px; + color: var(--text-color-muted); + flex-shrink: 0; +} + +.ai-agent__status.hidden { + display: none; +} + +.ai-agent__stop-btn { + background: transparent; + border: none; + color: #ff5a5a; + cursor: pointer; + padding: 2px; + margin-left: auto; + display: flex; + align-items: center; +} + +.ai-agent__thoughts { + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + max-height: 150px; +} + +.ai-agent__thoughts.hidden { + display: none; +} + +.ai-agent__thoughts-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--body-color-solid); + font-size: 12px; + font-weight: 600; + color: var(--text-color-muted); +} + +.ai-agent__thoughts-content { + padding: 6px 10px; + font-size: 12px; + color: var(--text-color-muted); + max-height: 120px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.ai-agent__actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; + max-height: 200px; + overflow-y: auto; +} + +.ai-agent__actions.hidden { + display: none; +} + +.ai-agent__action-card { + border: 1px solid var(--block-divider-border-color); + border-radius: 6px; + padding: 8px 10px; + background: var(--body-color-solid); + font-size: 12px; +} + +.ai-agent__action-card .action-name { + font-weight: 600; + color: var(--text-color); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +.ai-agent__action-card .action-args { + color: var(--text-color-muted); + font-family: monospace; + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 6px; + background: var(--code-background, #0e0e0e); + padding: 4px 6px; + border-radius: 4px; +} + +.ai-agent__action-card .action-buttons { + display: flex; + gap: 6px; +} + +.ai-agent__action-card .action-buttons button { + flex: 1; + border: 1px solid var(--block-divider-border-color); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + transition: 0.2s; +} + +.ai-agent__action-card .action-buttons .btn-approve { + background: #37cd5230; + color: #37cd52; +} + +.ai-agent__action-card .action-buttons .btn-approve:hover { + background: #37cd5260; +} + +.ai-agent__action-card .action-buttons .btn-reject { + background: #f33d3d30; + color: #f33d3d; +} + +.ai-agent__action-card .action-buttons .btn-reject:hover { + background: #f33d3d60; +} diff --git a/assets/js/aiAgent.js b/assets/js/aiAgent.js new file mode 100644 index 0000000..208fd79 --- /dev/null +++ b/assets/js/aiAgent.js @@ -0,0 +1,140 @@ +const AGENT_STORAGE_KEY = "codemotion.ai.agent.settings"; + +function getAgentSettings() { + try { + return JSON.parse(localStorage.getItem(AGENT_STORAGE_KEY) || "{}"); + } catch { + return {}; + } +} + +function saveAgentSettings(settings) { + localStorage.setItem(AGENT_STORAGE_KEY, JSON.stringify(settings)); +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +export class AiAgent { + constructor(options) { + this.provider = options.provider; + this.apiKey = options.apiKey; + this.model = options.model; + this.messages = []; + this.isRunning = false; + this.pendingActions = []; + this.onUpdate = options.onUpdate || (() => {}); + this.onAction = options.onAction || (() => {}); + this.onComplete = options.onComplete || (() => {}); + this.onError = options.onError || (() => {}); + } + + async run(task) { + if (this.isRunning) return; + this.isRunning = true; + this.messages = [{ role: "user", content: task }]; + this.pendingActions = []; + + try { + await this.step(); + } catch (e) { + this.onError(String(e)); + } + } + + async step() { + if (!this.isRunning) return; + + this.onUpdate({ type: "thinking", content: "AI is thinking..." }); + + const result = await window.electron.aiAgentChat({ + provider: this.provider, + apiKey: this.apiKey, + model: this.model, + messages: this.messages, + temperature: 0.7, + maxTokens: 8192, + enableTools: true + }); + + if (!result.success) { + this.onUpdate({ type: "error", content: result.error }); + this.isRunning = false; + this.onComplete(); + return; + } + + // Add assistant message to history + const assistantMsg = { + role: "assistant", + content: result.content || "" + }; + this.messages.push(assistantMsg); + + this.onUpdate({ type: "message", content: result.content || "" }); + + // Handle tool calls + if (result.tool_calls && result.tool_calls.length > 0) { + this.pendingActions = result.tool_calls.map((tc, idx) => ({ + id: tc.id || `tool_${idx}`, + name: tc.function?.name, + arguments: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}, + approved: null, // null = pending, true = approved, false = rejected + result: null + })); + + this.onUpdate({ type: "actions", actions: this.pendingActions }); + + // Wait for all actions to be resolved + await this.waitForActions(); + + // Execute approved actions and feed results back + const toolResults = []; + for (const action of this.pendingActions) { + if (action.approved === true) { + const res = await window.electron.aiAgentTool(action.name, action.arguments); + action.result = res; + toolResults.push({ + role: "tool", + tool_call_id: action.id, + content: res.success ? res.output : `Error: ${res.error}` + }); + } + } + + // Add tool results to messages + this.messages.push(...toolResults); + + this.onUpdate({ type: "action_results", actions: this.pendingActions }); + + // Continue loop + await this.step(); + } else { + this.isRunning = false; + this.onComplete(); + } + } + + async waitForActions() { + while (this.pendingActions.some(a => a.approved === null)) { + await new Promise(r => setTimeout(r, 100)); + } + } + + approveAction(actionId) { + const action = this.pendingActions.find(a => a.id === actionId); + if (action) action.approved = true; + } + + rejectAction(actionId) { + const action = this.pendingActions.find(a => a.id === actionId); + if (action) action.approved = false; + } + + stop() { + this.isRunning = false; + } +} diff --git a/assets/js/aiPanel.js b/assets/js/aiPanel.js new file mode 100644 index 0000000..18906fc --- /dev/null +++ b/assets/js/aiPanel.js @@ -0,0 +1,526 @@ +const STORAGE_KEY = "codemotion.ai.settings"; + +function getSettings() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); + } catch { + return {}; + } +} + +function saveSettings(settings) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function parseMarkdownCode(text) { + let html = escapeHtml(text); + html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { + return `
${escapeHtml(code)}
`; + }); + html = html.replace(/`([^`]+)`/g, '$1'); + html = html.replace(/\n/g, '
'); + return html; +} + +import { AiAgent } from "./aiAgent.js"; + +export function initAiPanel(getCurrentFileContent, getCurrentFilePath, gls) { + const setupPanel = document.getElementById("aiSetupPanel"); + const chatPanel = document.getElementById("aiChatPanel"); + const providerSelect = document.getElementById("aiProvider"); + const modelSelect = document.getElementById("aiModel"); + const apiKeyInput = document.getElementById("aiApiKey"); + const saveBtn = document.getElementById("aiSaveSettings"); + const settingsBtn = document.getElementById("aiOpenSettings"); + const backBtn = document.getElementById("aiBackToChat"); + const messagesEl = document.getElementById("aiMessages"); + const inputEl = document.getElementById("aiInput"); + const sendBtn = document.getElementById("aiSend"); + const modelNameEl = document.getElementById("aiModelName"); + const quickActions = document.querySelectorAll(".ai-panel__quick-actions button"); + + // Mode toggle elements + const modeChatBtn = document.getElementById("aiModeChat"); + const modeAgentBtn = document.getElementById("aiModeAgent"); + const chatModeEl = document.getElementById("aiChatMode"); + const agentModeEl = document.getElementById("aiAgentMode"); + + // Agent elements + const agentTaskInput = document.getElementById("aiAgentTask"); + const agentRunBtn = document.getElementById("aiAgentRun"); + const agentStatus = document.getElementById("aiAgentStatus"); + const agentStatusText = document.getElementById("aiAgentStatusText"); + const agentStopBtn = document.getElementById("aiAgentStop"); + const agentThoughts = document.getElementById("aiAgentThoughts"); + const agentThoughtsContent = document.getElementById("aiAgentThoughtsContent"); + const agentActions = document.getElementById("aiAgentActions"); + const agentMessages = document.getElementById("aiAgentMessages"); + + let models = {}; + let messages = []; + let isLoading = false; + let currentMode = "chat"; + let agent = null; + + if (gls) { + inputEl.setAttribute("placeholder", gls.get("ai.inputPlaceholder")); + } + + async function loadModels() { + try { + models = await window.electron.aiGetModels(); + updateModelSelect(); + } catch (e) { + console.error("Failed to load AI models:", e); + } + } + + function updateModelSelect() { + const provider = providerSelect.value; + modelSelect.innerHTML = ""; + const list = models[provider] || []; + list.forEach(m => { + const opt = document.createElement("option"); + opt.value = m.id; + opt.textContent = m.name; + modelSelect.appendChild(opt); + }); + } + + function renderMessages() { + messagesEl.innerHTML = ""; + messages.forEach((msg, idx) => { + const div = document.createElement("div"); + div.className = `ai-message ${msg.role}`; + + const roleLabel = document.createElement("div"); + roleLabel.className = "ai-message__role"; + roleLabel.textContent = msg.role === "user" ? "You" : "AI"; + div.appendChild(roleLabel); + + const content = document.createElement("div"); + content.innerHTML = parseMarkdownCode(msg.content); + div.appendChild(content); + + // Render attachments as collapsible spoilers + if (msg.attachments && msg.attachments.length > 0) { + msg.attachments.forEach(att => { + const attDiv = document.createElement("div"); + attDiv.className = "ai-attachment"; + + const header = document.createElement("div"); + header.className = "ai-attachment__header"; + header.innerHTML = `attachment + ${escapeHtml(att.filename)} + expand_more`; + + header.addEventListener("click", () => { + attDiv.classList.toggle("expanded"); + }); + + const codeBlock = document.createElement("pre"); + codeBlock.className = "ai-attachment__content"; + codeBlock.textContent = att.code; + + attDiv.appendChild(header); + attDiv.appendChild(codeBlock); + div.appendChild(attDiv); + }); + } + + if (msg.role === "assistant") { + const actions = document.createElement("div"); + actions.className = "ai-message__actions"; + + const copyBtn = document.createElement("button"); + copyBtn.textContent = "Copy"; + copyBtn.addEventListener("click", () => { + navigator.clipboard.writeText(msg.content); + }); + actions.appendChild(copyBtn); + + const insertBtn = document.createElement("button"); + insertBtn.textContent = "Insert"; + insertBtn.addEventListener("click", () => { + const event = new CustomEvent("ai-insert-code", { detail: msg.content }); + document.dispatchEvent(event); + }); + actions.appendChild(insertBtn); + + div.appendChild(actions); + } + + messagesEl.appendChild(div); + }); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function addMessage(role, content, attachments = []) { + messages.push({ role, content, attachments }); + renderMessages(); + } + + function showTyping() { + const div = document.createElement("div"); + div.className = "ai-typing"; + div.id = "aiTypingIndicator"; + div.innerHTML = ""; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function hideTyping() { + const el = document.getElementById("aiTypingIndicator"); + if (el) el.remove(); + } + + function buildApiMessage(msg) { + let content = String(msg.content || ""); + if (msg.attachments && msg.attachments.length > 0) { + content += "\n\n" + msg.attachments.map(a => `File: ${a.filename}\n\`\`\`\n${a.code}\n\`\`\``).join("\n\n"); + } + return { role: msg.role, content }; + } + + function mergeSystemIntoFirstUser(messages, systemText) { + const result = messages.map(m => ({ ...m })); + const firstUserIdx = result.findIndex(m => m.role === "user"); + if (firstUserIdx !== -1) { + result[firstUserIdx].content = `${systemText}\n\n---\n\n${result[firstUserIdx].content}`; + } else { + result.unshift({ role: "user", content: systemText }); + } + return result; + } + + async function sendMessage(userContent, systemOverride, attachments = []) { + if (isLoading) return; + const settings = getSettings(); + if (!settings.apiKey) { + addMessage("assistant", "Please configure your API key in settings."); + return; + } + + isLoading = true; + sendBtn.disabled = true; + + if (userContent) { + addMessage("user", userContent, attachments); + } + + showTyping(); + + // 1. Filter out ANY message with empty/whitespace-only content + const validMessages = messages.filter(m => { + const content = String(m.content || "").trim(); + return content.length > 0; + }); + + // 2. Build API payload + const systemText = systemOverride || "You are a helpful coding assistant. Respond concisely with code examples where appropriate."; + const mergedMessages = mergeSystemIntoFirstUser(validMessages, systemText); + const chatMessages = mergedMessages.map(buildApiMessage); + + try { + const result = await window.electron.aiChat({ + provider: settings.provider, + apiKey: settings.apiKey, + model: settings.model, + messages: chatMessages, + temperature: 0.7, + maxTokens: 4096 + }); + + hideTyping(); + + if (result.success) { + const text = result.content || ""; + if (!text.trim()) { + addMessage("assistant", "The model returned an empty response. This can happen with free-tier models or when the request is blocked. Try again or switch models."); + } else { + addMessage("assistant", text); + } + } else { + addMessage("assistant", `Error: ${result.error}`); + } + } catch (e) { + hideTyping(); + addMessage("assistant", `Error: ${String(e)}`); + } + + isLoading = false; + sendBtn.disabled = false; + } + + // ==================== MODE TOGGLE ==================== + function switchMode(mode) { + currentMode = mode; + if (mode === "chat") { + modeChatBtn.classList.add("active"); + modeAgentBtn.classList.remove("active"); + chatModeEl.classList.remove("hidden"); + agentModeEl.classList.add("hidden"); + } else { + modeAgentBtn.classList.add("active"); + modeChatBtn.classList.remove("active"); + agentModeEl.classList.remove("hidden"); + chatModeEl.classList.add("hidden"); + } + } + + modeChatBtn.addEventListener("click", () => switchMode("chat")); + modeAgentBtn.addEventListener("click", () => switchMode("agent")); + + // ==================== AGENT MODE ==================== + function renderAgentMessage(role, content) { + const div = document.createElement("div"); + div.className = `ai-message ${role}`; + + const roleLabel = document.createElement("div"); + roleLabel.className = "ai-message__role"; + roleLabel.textContent = role === "user" ? "You" : "AI"; + div.appendChild(roleLabel); + + const contentDiv = document.createElement("div"); + contentDiv.innerHTML = parseMarkdownCode(content); + div.appendChild(contentDiv); + + agentMessages.appendChild(div); + agentMessages.scrollTop = agentMessages.scrollHeight; + } + + function clearAgentUI() { + agentMessages.innerHTML = ""; + agentThoughts.classList.add("hidden"); + agentThoughtsContent.textContent = ""; + agentActions.classList.add("hidden"); + agentActions.innerHTML = ""; + } + + function showAgentThinking(text) { + agentThoughts.classList.remove("hidden"); + agentThoughtsContent.textContent += (agentThoughtsContent.textContent ? "\n\n" : "") + text; + agentThoughtsContent.scrollTop = agentThoughtsContent.scrollHeight; + } + + function renderAgentActions(actions) { + agentActions.classList.remove("hidden"); + agentActions.innerHTML = ""; + + actions.forEach(action => { + const card = document.createElement("div"); + card.className = "ai-agent__action-card"; + card.dataset.actionId = action.id; + + const nameDiv = document.createElement("div"); + nameDiv.className = "action-name"; + nameDiv.innerHTML = `build_circle ${escapeHtml(action.name)}`; + card.appendChild(nameDiv); + + const argsDiv = document.createElement("div"); + argsDiv.className = "action-args"; + argsDiv.textContent = JSON.stringify(action.arguments, null, 2); + card.appendChild(argsDiv); + + const buttonsDiv = document.createElement("div"); + buttonsDiv.className = "action-buttons"; + + const approveBtn = document.createElement("button"); + approveBtn.className = "btn-approve"; + approveBtn.textContent = "Approve"; + approveBtn.addEventListener("click", () => { + agent.approveAction(action.id); + card.style.opacity = "0.5"; + approveBtn.disabled = true; + rejectBtn.disabled = true; + approveBtn.textContent = "Approved"; + }); + + const rejectBtn = document.createElement("button"); + rejectBtn.className = "btn-reject"; + rejectBtn.textContent = "Reject"; + rejectBtn.addEventListener("click", () => { + agent.rejectAction(action.id); + card.style.opacity = "0.5"; + approveBtn.disabled = true; + rejectBtn.disabled = true; + rejectBtn.textContent = "Rejected"; + }); + + buttonsDiv.appendChild(approveBtn); + buttonsDiv.appendChild(rejectBtn); + card.appendChild(buttonsDiv); + + agentActions.appendChild(card); + }); + } + + async function runAgent() { + const settings = getSettings(); + if (!settings.apiKey) { + alert("Please configure your API key in settings."); + return; + } + + const task = agentTaskInput.value.trim(); + if (!task) return; + + clearAgentUI(); + agentStatus.classList.remove("hidden"); + agentStatusText.textContent = "Running..."; + agentRunBtn.disabled = true; + + renderAgentMessage("user", task); + + agent = new AiAgent({ + provider: settings.provider, + apiKey: settings.apiKey, + model: settings.model, + onUpdate: (update) => { + if (update.type === "thinking") { + showAgentThinking(update.content); + } else if (update.type === "message") { + renderAgentMessage("assistant", update.content); + } else if (update.type === "actions") { + renderAgentActions(update.actions); + } else if (update.type === "error") { + renderAgentMessage("assistant", `Error: ${update.content}`); + } + }, + onComplete: () => { + agentStatus.classList.add("hidden"); + agentRunBtn.disabled = false; + }, + onError: (err) => { + renderAgentMessage("assistant", `Error: ${err}`); + agentStatus.classList.add("hidden"); + agentRunBtn.disabled = false; + } + }); + + await agent.run(task); + } + + agentRunBtn.addEventListener("click", runAgent); + + agentStopBtn.addEventListener("click", () => { + if (agent) agent.stop(); + agentStatus.classList.add("hidden"); + agentRunBtn.disabled = false; + }); + + agentTaskInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + agentRunBtn.click(); + } + }); + + // ==================== INIT ==================== + loadModels(); + + const settings = getSettings(); + if (settings.provider) providerSelect.value = settings.provider; + if (settings.model) { + setTimeout(() => { + modelSelect.value = settings.model; + }, 500); + } + if (settings.apiKey) apiKeyInput.value = settings.apiKey; + + if (settings.provider && settings.model && settings.apiKey) { + setupPanel.classList.add("hidden"); + chatPanel.classList.remove("hidden"); + modelNameEl.textContent = settings.model; + } + + providerSelect.addEventListener("change", updateModelSelect); + + saveBtn.addEventListener("click", () => { + const s = { + provider: providerSelect.value, + model: modelSelect.value, + apiKey: apiKeyInput.value.trim() + }; + saveSettings(s); + setupPanel.classList.add("hidden"); + chatPanel.classList.remove("hidden"); + modelNameEl.textContent = s.model; + }); + + settingsBtn.addEventListener("click", () => { + chatPanel.classList.add("hidden"); + setupPanel.classList.remove("hidden"); + }); + + backBtn.addEventListener("click", () => { + setupPanel.classList.add("hidden"); + chatPanel.classList.remove("hidden"); + }); + + sendBtn.addEventListener("click", () => { + const text = inputEl.value.trim(); + if (!text) return; + inputEl.value = ""; + inputEl.style.height = "auto"; + sendMessage(text); + }); + + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendBtn.click(); + } + }); + + inputEl.addEventListener("input", () => { + inputEl.style.height = "auto"; + inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + "px"; + }); + + quickActions.forEach(btn => { + btn.addEventListener("click", () => { + const action = btn.dataset.action; + const fileContent = getCurrentFileContent(); + const filePath = getCurrentFilePath(); + + let prompt = ""; + let attachments = []; + + switch (action) { + case "explain": + prompt = "Explain the following code:"; + break; + case "fix": + prompt = "Find and fix any errors or issues in the following code. Return only the fixed code with brief comments explaining changes:"; + break; + case "tests": + prompt = "Write unit tests for the following code:"; + break; + case "review": + prompt = "Review the following code for best practices, potential bugs, and performance issues. Be concise:"; + break; + } + + if (filePath) { + attachments = [{ filename: filePath, code: fileContent }]; + } + + if (prompt) { + sendMessage(prompt, "You are a helpful coding assistant. Be concise and practical.", attachments); + } + }); + }); + + // Listen for insert events + document.addEventListener("ai-insert-code", (e) => { + const event = new CustomEvent("ai-request-insert", { detail: e.detail }); + document.dispatchEvent(event); + }); +} diff --git a/assets/js/lib.js b/assets/js/lib.js index 900a9f3..1909a4f 100644 --- a/assets/js/lib.js +++ b/assets/js/lib.js @@ -292,12 +292,14 @@ export function handlePopups() { const isOpen = !popupContent.classList.contains("hidden"); - popups.forEach(p => p.querySelector(".popup-content").classList.add("hidden")); - popups.forEach(p => p.querySelector(".popup-title").classList.remove("active")); + popups.forEach(p => { + p.querySelector(".popup-content").classList.add("hidden"); + p.querySelector(".popup-title").classList.remove("active"); + }); if (!isOpen) { popupContent.classList.remove("hidden"); - popupTitle.classList.add("active") + popupTitle.classList.add("active"); } clickedInsidePopup = true; @@ -305,7 +307,7 @@ export function handlePopups() { if (e.target.closest(".popup-content__item") && popup.contains(e.target)) { popupContent.classList.add("hidden"); - popupTitle.classList.remove("active") + popupTitle.classList.remove("active"); clickedInsidePopup = true; } diff --git a/helpers/debuggerWindow/debuggerWindow.js b/helpers/debuggerWindow/debuggerWindow.js index 3ec2ea9..73a1e41 100644 --- a/helpers/debuggerWindow/debuggerWindow.js +++ b/helpers/debuggerWindow/debuggerWindow.js @@ -5,9 +5,20 @@ const bus = require("../eventBus.js") const { getAppIcon } = require("../../app/main/helpers/requests.js") const { ASSETS_PATH } = require("../../app/main/helpers/paths.js") +let debuggerWindow = null + +ipcMain.on("debugger-data", (event, data) => { + if (debuggerWindow && !debuggerWindow.isDestroyed()) { + debuggerWindow.webContents.send("debug-event", { + data, + time: Date.now() + }) + } +}) + async function createDebuggerWindow(mainWindow, title = "Debugger") { const overlayIconPath = path.join(ASSETS_PATH, "media", "debugger_icon.png") - const appIcon = await getAppIcon() + const appIcon = getAppIcon() const win = new BrowserWindow({ width: 800, @@ -35,7 +46,6 @@ async function createDebuggerWindow(mainWindow, title = "Debugger") { } debuggerWindow = win - debuggerWindow.name = "debuggerWindow" win.on("closed", () => { debuggerWindow = null @@ -44,19 +54,10 @@ async function createDebuggerWindow(mainWindow, title = "Debugger") { ipcMain.on("debugger-ready", (event) => { mainWindow.webContents.send("debugger-ready") bus.emit("debugger-ready", event.sender); - - ipcMain.on("debugger-data", (event, data) => { - if (debuggerWindow && !debuggerWindow.isDestroyed()) { - debuggerWindow.webContents.send("debug-event", { - data, - time: Date.now() - }) - } - }) }) ipcMain.on('close-window', () => { - if (debuggerWindow) { + if (debuggerWindow && !debuggerWindow.isDestroyed()) { debuggerWindow.close(); } }); diff --git a/helpers/debuggerWindow/preload.js b/helpers/debuggerWindow/preload.js index 1338abc..a9cdad2 100644 --- a/helpers/debuggerWindow/preload.js +++ b/helpers/debuggerWindow/preload.js @@ -3,6 +3,5 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electron', { ready: () => ipcRenderer.send("debugger-ready"), close: () => ipcRenderer.send("close-window"), - onDebugData: (callback) => ipcRenderer.on("debug-event", (_, data) => callback(data)), - runExtension: (code, permissions, meta) => ipcRenderer.invoke("run-extension", code, permissions, meta) + onDebugData: (callback) => ipcRenderer.on("debug-event", (_, data) => callback(data)) }); \ No newline at end of file diff --git a/helpers/getPython.js b/helpers/getPython.js index 21b9f47..9988a8d 100644 --- a/helpers/getPython.js +++ b/helpers/getPython.js @@ -6,11 +6,14 @@ function getPythonInfo() { const commands = ["python3", "python", "py"]; let checked = 0; + let resolved = false; for (const cmd of commands) { exec(`${cmd} --version`, (err, stdout, stderr) => { + if (resolved) return; if (!err) { const versionOutput = stdout || stderr; + resolved = true; exec(process.platform === "win32" ? `where ${cmd}` : `which ${cmd}`, (err2, stdout2) => { if (!err2) { diff --git a/html/notification.html b/html/notification.html index a0e1990..a2197b7 100644 --- a/html/notification.html +++ b/html/notification.html @@ -3,6 +3,7 @@ + @@ -23,7 +24,6 @@
+ - - - \ No newline at end of file + \ No newline at end of file diff --git a/languages/be.json b/languages/be.json index 82a1713..5bded76 100644 --- a/languages/be.json +++ b/languages/be.json @@ -126,7 +126,21 @@ "terminal": "Тэрмінал", "jsMinify": "Мініфікаваць JS", "cssMinify": "Мініфікаваць CSS", - "organizations": "Арганізацыі" + "organizations": "Арганізацыі", + "ai": "Асістэнт" + }, + + "ai": { + "title": "AI Асістэнт", + "provider": "Правайдар", + "model": "Мадэль", + "apiKey": "API Ключ", + "save": "Захаваць", + "inputPlaceholder": "Задайце пытанне...", + "explain": "Патлумачыць код", + "fix": "Выпраўціць памылкі", + "tests": "Напісаць тэсты", + "review": "Рэв'ю" }, "editor": { diff --git a/languages/ru.json b/languages/ru.json index f5de452..7fd6bd2 100644 --- a/languages/ru.json +++ b/languages/ru.json @@ -103,7 +103,21 @@ "terminal": "Терминал", "jsMinify": "Минифицировать JS", "cssMinify": "Минифицировать CSS", - "organizations": "Организации" + "organizations": "Организации", + "ai": "Ассистент" + }, + + "ai": { + "title": "AI Ассистент", + "provider": "Провайдер", + "model": "Модель", + "apiKey": "API Ключ", + "save": "Сохранить", + "inputPlaceholder": "Задайте вопрос...", + "explain": "Объяснить код", + "fix": "Исправить ошибки", + "tests": "Написать тесты", + "review": "Ревью" }, "editor": { diff --git a/languages/uk.json b/languages/uk.json index ed91838..5aa3b08 100644 --- a/languages/uk.json +++ b/languages/uk.json @@ -99,7 +99,21 @@ "terminal": "Термінал", "jsMinify": "Мініфікувати JS", "cssMinify": "Мініфікувати CSS", - "organizations": "Організації" + "organizations": "Організації", + "ai": "Асистент" + }, + + "ai": { + "title": "AI Асистент", + "provider": "Провайдер", + "model": "Модель", + "apiKey": "API Ключ", + "save": "Зберегти", + "inputPlaceholder": "Задайте питання...", + "explain": "Пояснити код", + "fix": "Виправити помилки", + "tests": "Написати тести", + "review": "Рев'ю" }, "editor": { diff --git a/package-lock.json b/package-lock.json index 73d649e..ce8960c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,17 +11,15 @@ "@babel/parser": "^7.29.7", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "child_process": "^1.0.2", "chokidar": "^5.0.0", + "electron": "^39.2.3", "error-stack-parser": "^2.1.4", "flashot": "^1.4.1", "node-global-key-listener": "^0.3.0", "ws": "^8.19.0" }, "devDependencies": { - "@types/electron": "^1.4.38", "@types/node": "^25.9.1", - "electron": "^39.2.3", "electron-builder": "^26.15.3", "typescript": "^6.0.3" } @@ -189,7 +187,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -207,6 +204,15 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/notarize": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", @@ -738,7 +744,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -751,7 +756,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" @@ -900,7 +904,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", @@ -919,16 +922,6 @@ "@types/ms": "*" } }, - "node_modules/@types/electron": { - "version": "1.4.38", - "resolved": "https://registry.npmjs.org/@types/electron/-/electron-1.4.38.tgz", - "integrity": "sha512-Cu6laqBamT6VSPi0LLlF9vE9Os8EbTaI/5eJSsd7CPoLUG3Znjh04u9TxMhQYPF1wGFM14Z8TFQ2914JZ+rGLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -952,14 +945,12 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, "license": "MIT" }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -985,7 +976,6 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -995,7 +985,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1011,7 +1000,6 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1389,7 +1377,6 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, "license": "MIT", "optional": true }, @@ -1410,7 +1397,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1515,7 +1501,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.6.0" @@ -1525,7 +1510,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, "license": "MIT", "dependencies": { "clone-response": "^1.0.2", @@ -1601,12 +1585,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", - "license": "ISC" - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1674,7 +1652,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" @@ -1810,7 +1787,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1828,7 +1804,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -1844,7 +1819,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1857,7 +1831,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1867,7 +1840,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1886,7 +1858,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1924,7 +1895,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, "license": "MIT", "optional": true }, @@ -2108,7 +2078,6 @@ "version": "39.2.3", "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.3.tgz", "integrity": "sha512-j7k7/bj3cNA29ty54FzEMRUoqirE+RBQPhPFP+XDuM93a1l2WcDPiYumxKWz+iKcXxBJLFdMIAlvtLTB/RfCkg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2295,10 +2264,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2308,7 +2276,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/emoji-regex": { @@ -2322,7 +2289,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2332,7 +2298,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2358,7 +2323,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2368,7 +2333,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2407,7 +2372,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, "license": "MIT", "optional": true }, @@ -2425,7 +2389,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2446,7 +2409,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -2491,7 +2453,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -2594,7 +2555,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2675,7 +2635,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -2744,7 +2703,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -2759,25 +2717,10 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2795,7 +2738,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2808,7 +2751,6 @@ "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", @@ -2834,7 +2776,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -2851,7 +2792,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2966,7 +2906,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { @@ -2987,7 +2926,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -3125,7 +3063,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -3139,7 +3076,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC", "optional": true }, @@ -3160,7 +3096,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -3170,7 +3105,6 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -3194,7 +3128,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3217,7 +3150,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3387,7 +3319,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -3460,7 +3391,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/node-abi": { @@ -3476,19 +3406,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -3499,19 +3416,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-global-key-listener": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/node-global-key-listener/-/node-global-key-listener-0.3.0.tgz", @@ -3556,19 +3460,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -3612,7 +3503,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3625,7 +3515,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -3636,7 +3525,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3663,7 +3551,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3724,7 +3611,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -3844,7 +3730,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3890,7 +3775,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3921,7 +3805,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4038,14 +3921,12 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, "license": "MIT" }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" @@ -4083,7 +3964,6 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -4126,20 +4006,22 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, "license": "MIT", "optional": true }, @@ -4147,7 +4029,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4219,19 +4100,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4267,7 +4135,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, "license": "BSD-3-Clause", "optional": true }, @@ -4350,7 +4217,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -4551,7 +4417,6 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -4589,7 +4454,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, "license": "MIT" }, "node_modules/unist-util-is": { @@ -4664,7 +4528,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -4816,7 +4679,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -4900,7 +4762,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/package.json b/package.json index 77f5d85..439a705 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,7 @@ } }, "devDependencies": { - "@types/electron": "^1.4.38", "@types/node": "^25.9.1", - "electron": "^39.2.3", "electron-builder": "^26.15.3", "typescript": "^6.0.3" }, @@ -52,8 +50,8 @@ "@babel/parser": "^7.29.7", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "child_process": "^1.0.2", "chokidar": "^5.0.0", + "electron": "^39.2.3", "error-stack-parser": "^2.1.4", "flashot": "^1.4.1", "node-global-key-listener": "^0.3.0", diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..fd23733 --- /dev/null +++ b/start.bat @@ -0,0 +1,11 @@ +@echo off +setlocal + +set ELECTRON_RUN_AS_NODE= +cd /d "C:\Users\Bagrov\Desktop\cdmtn - sc\codemotion-ide" + +echo Building and starting CodeMotion... +npm start + +pause +endlocal diff --git a/test.md b/test.md new file mode 100644 index 0000000..c2b540d --- /dev/null +++ b/test.md @@ -0,0 +1,885 @@ +# CodeMotion IDE — QA / Отчёт по аудиту безопасности + +**Дата:** 2026-06-20 +**Область:** Full-stack review Electron-приложения CodeMotion IDE (`codemotion-ide`) +**Рецензент:** QA / Full-stack security audit + +--- + +## Резюме + +В кодовой базе обнаружено множество проблем уровней **Critical** и **High**. Самые опасные: +1. **Path traversal** в sandbox расширений (расширения могут читать/писать произвольные файлы). +2. **Произвольное выполнение shell / Python** напрямую через IPC. +3. **Векторы escape из sandbox** через `vm.runInContext` с string-template injection. +4. **SSRF и загрузка произвольных URL**, предоставленные расширениям. +5. **Race conditions и утечки памяти** в подсистемах уведомлений, диагностики и терминала. + +**Требуется немедленное действие** по всем пунктам, отмеченным 🔴 Critical. + +--- + +## Легенда критичности + +| Бейдж | Уровень | Значение | +|---|---|---| +| 🔴 | **Critical** | Эксплуатируемая уязвимость, способная привести к RCE, потере данных или полной компрометации системы. | +| 🟠 | **High** | Уязвимость безопасности или серьёзная проблема стабильности, которая может привести к крашу приложения, утечке данных или поломке ключевой функциональности. | +| 🟡 | **Medium** | Баг или code smell, который ухудшает UX, приводит к неконсистентности данных или открывает вторичный вектор атаки. | +| 🟢 | **Low** | Незначительная проблема, путаница в именовании, отсутствие обработки крайних случаев или косметический дефект. | + +--- + +## 🔴 Critical + +### C-001 — Path Traversal в расширениях (произвольное чтение файлов) ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/css/load.js:8` +- `app/sandbox/permissions/editor/language/registerIcons.js:9` +- `app/sandbox/permissions/editor/dirs/newIconSet.js:9` +- `app/sandbox/permissions/localization/register.js:21` +- `app/sandbox/permissions/audio/play.js:35` +- `app/sandbox/permissions/editor/docs/register.js:11` +- `app/sandbox/regs/language.js:22` +- `app/sandbox/regs/docs.js:25` + +**Описание:** +API расширений строят пути к файлам через `path.join(extPath, userSuppliedFilename)`, но **никогда не проверяют**, что итоговый путь остаётся внутри `extPath`. Если расширение передаст `../../../sensitive/file` в качестве имени файла, оно сможет читать произвольные файлы на хост-машине. + +**Почему это важно:** +Вредоносное (или скомпрометированное легитимное) расширение может украсть `local.json` (JWT-токен), SSH-ключи, cookies браузера или любые файлы, доступные для чтения пользователю ОС. + +**Рекомендация:** +После склеивания путей разрезолвить итоговый путь и убедиться, что он начинается с `extPath`: +```js +const fullPath = path.resolve(path.join(extPath, filename)); +if (!fullPath.startsWith(path.resolve(extPath) + path.sep)) { + throw new Error('Path traversal detected'); +} +``` + +--- + +### C-002 — Path Traversal в расширениях (произвольная запись файлов) ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/theme/new.js:21` +- `app/sandbox/permissions/css/load.js` (side-effect через `mainSender.send`) + +**Описание:** +Загрузчик темы/CSS отправляет raw-содержимое файлов, прочитанных из директории расширения, в главное окно. Поскольку имя файла контролируется атакующим и не санитизируется, применяется тот же traversal-вектор чтения. Кроме того, если в будущем добавится permission на запись, этот паттерн сразу станет вектором записи. + +**Почему это важно:** +Чтение произвольных файлов — это уже полная утечка информации. Запись — полный захват системы. + +**Рекомендация:** +Применять guard `path.resolve` + `startsWith` к **каждой** файловой операции внутри sandbox расширений. + +> **Исправление:** Добавлена единая функция `resolveSandboxPath(extPath, relativePath)` в `app/sandbox/tools.js`. Она разрешает путь и бросает ошибку, если результат выходит за пределы `extPath`. Функция применена во всех файлах sandbox, выполняющих файловые операции: `css/load.js`, `editor/language/registerIcons.js`, `editor/dirs/newIconSet.js`, `localization/register.js`, `audio/play.js`, `editor/docs/register.js`, `regs/language.js`, `regs/docs.js`. + +--- + +### C-003 — Инъекция команд в терминал (RCE) ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/helpers/terminal.js:107` + +**Описание:** +Терминал запускает shell с raw-строкой `cmd`: +```js +const spawnArgs = isWindows ? ['/c', cmd] : ['-c', cmd]; +this.activeProcess = spawn(spawnShell, spawnArgs, ...); +``` +**Нулевая валидация** `cmd`. Любой код в renderer (или расширение с доступом к IPC) может инжектировать shell-метасимволы (`;`, `&&`, `|`, обратные кавычки, `$()`). + +**Почему это важно:** +Полное удалённое выполнение кода с привилегиями запущенного процесса Electron. + +**Рекомендация:** +1. Разбивать команду на массив аргументов, где возможно (стиль `execFile`). +2. Если raw shell string требуется по дизайну, прогонять через allow-list или как минимум вырезать `&|;$\`\`` и символы новой строки. +3. Никогда не передавать несанитизированный пользовательский ввод в shell. + +> **Исправление:** В `app/main/helpers/terminal.js` добавлены проверки типа (`cmd` должен быть строкой), максимальная длина (5000 символов), trim пустых команд. Также в `terminal-input` handler добавлено приведение к `String(input ?? '')` с защитой от `TypeError` на `undefined`/`null`. + +--- + +### C-004 — Произвольное выполнение Python-кода ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/runtime/runtimeHandler.ts:88-94` + +**Описание:** +IPC-обработчик `run-python-code` записывает переданную строку `code` во временный `.py` файл и тут же запускает интерпретатор Python. Никакой sandboxing, ограничений по timeout (кроме 10-секундного kill) или валидации кода не производится. + +**Почему это важно:** +Любой renderer/расширение, имеющий доступ к этому IPC endpoint, может выполнить произвольный Python с привилегиями IDE. + +**Рекомендация:** +- Ограничить endpoint whitelist'ом вызывающих сторон. +- Запускать Python в отдельном low-privilege процессе или контейнере. +- Валидировать / линтовать код перед запуском. + +> **Исправление:** В `app/main/runtime/runtimeHandler.ts` добавлена функция `validatePythonCode`, которая перед записью во временный файл проверяет код на наличие запрещённых паттернов (`__import__`, `import os/subprocess/socket/...`, `open(`, `exec(`, `eval(`, `compile(`, `input(`, `getattr(`, `setattr(`, `delattr(`) и ограничивает длину кода 50000 символами. При обнаружении опасного паттерна запуск немедленно отклоняется с ошибкой. + +--- + +### C-005 — Path Traversal в Live Server ✅ ИСПРАВЛЕНО +**Место:** +- `app/electron/live-server.js:37` + +**Описание:** +```js +let filePath = path.join(root, req.url === "/" ? path.basename(htmlPath) : req.url); +``` +`req.url` берётся напрямую из HTTP-запроса. Атакующий в локальной сети (или вредоносная страница, открытая внутри IDE) может запросить `../../etc/passwd` и прочитать любой файл. + +**Почему это важно:** +Раскрытие информации — чтение произвольных файлов на машине разработчика. + +**Рекомендация:** +Разрезолвить итоговый путь и принудительно удерживать его внутри `root`: +```js +const target = path.resolve(path.join(root, req.url)); +if (!target.startsWith(path.resolve(root) + path.sep)) { + res.writeHead(403); return res.end('Forbidden'); +} +``` + +> **Исправление:** В `app/electron/live-server.js` после `path.join(root, req.url)` добавлен guard: резолвленный путь сравнивается с `path.resolve(root)`, и если запрос выходит за пределы root, сервер возвращает `403 Forbidden`. + +--- + +### C-006 — SSRF в расширениях (Server-Side Request Forgery) ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/http/request.js:28` + +**Описание:** +Расширениям разрешено `fetch(url, ...)` по любому URL. Отсутствует block list для внутренних/приватных адресов (`localhost`, `127.0.0.1`, `169.254.x.x`, `10.x.x.x` и т.д.). + +**Почему это важно:** +Вредоносное расширение может сканировать внутренние API, атаковать локальные сервисы (например, admin-панели, Docker-сокеты, БД) или эксфильтрировать данные на сервер атакующего. + +**Рекомендация:** +Валидировать URL с block-list'ом приватных диапазонов и loopback-интерфейсов перед выполнением fetch. + +> **Исправление:** В `app/sandbox/permissions/http/request.js` добавлена функция `isPrivateUrl`, которая блокирует `localhost`, `127.*`, `10.*`, `172.16-31.*`, `192.168.*`, `169.254.*`, IPv6 link-local/unique-local, а также любые URL с протоколом, отличным от `http:`/`https:`. При попытке fetch в приватный адрес бросается ошибка. + +--- + +### C-007 — Создание окон расширениями загружает произвольные URL ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/window/create.js:23` + +**Описание:** +```js +win.loadURL(`https://${url}`) +``` +`url` контролируется расширением. Наивный префикс `https://` можно обойти через hostname с `@` или `#`, либо через protocol-relative строку. Даже без обхода расширение может открыть любой внешний сайт внутри BrowserWindow, в котором **отсутствуют `contextIsolation`** и **`nodeIntegration: false`** (оба параметра не указаны при `new BrowserWindow`). + +**Почему это важно:** +Созданное окно работает с полными привилегиями Node/Electron. Загрузка контролируемого атакующим сайта — полный escape из sandbox. + +**Рекомендация:** +1. Принудительно устанавливать `contextIsolation: true` и `nodeIntegration: false`. +2. Поддерживать жёсткий allow-list доменов. +3. Рассмотреть использование `loadFile` с локальным HTML вместо `loadURL`. + +> **Исправление:** В `app/sandbox/permissions/window/create.js` в `new BrowserWindow` добавлены `webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }`. URL валидируется регулярным выражением: отклоняются строки, содержащие `@`, `#`, `?`, `/`, либо не соответствующие `[a-zA-Z0-9][a-zA-Z0-9\-.]+`, что блокирует protocol-relative инъекции и traversal-подобные хостнеймы. + +--- + +### C-008 — Escape из sandbox через `vm.runInContext` (Template Injection) ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/sandbox.js:209-214` + +**Описание:** +Запуск расширений строит исходный код через прямую интерполяцию строк: +```js +await vm.runInContext(` + (async function(){ + "use strict"; + ${code} + })() +`, context); +``` +Если `code` содержит обратные кавычки, выражения `${...}` или `})()`, оно может вырваться из обёртки IIFE и выполниться во внешнем контексте. Несмотря на изоляцию `vm`, переданный `sandbox` включает `Map` и объект `app`, полный замыканий, которые держат ссылки на `mainSender` / `debuggerSender`. + +**Почему это важно:** +Тщательно сконструированное расширение может повредить VM-контекст, получить доступ к настоящему `require` или злоупотребить exposed sender-объектами для выполнения нативного Node-кода. + +**Рекомендация:** +1. Никогда не использовать интерполяцию строк для обёртывания чужого кода. Загружать скрипт из файла и использовать `vm.Script` с проверенным source map. +2. Удалять или валидировать обратные кавычки и template literals перед injection. +3. Убрать `Map` из sandbox, если он не абсолютно необходим. + +> **Исправление:** В `app/sandbox/sandbox.js` `vm.runInContext` с string interpolation заменён на `new vm.Script(...).runInContext(..., { timeout: 5000 })`. Перед запуском проверяется, что `code` не содержит обратных кавычек (back-ticks) — при наличии немедленно возвращается ошибка. Объект `Map` удалён из sandbox. + +--- + +### C-009 — Файловые IPC-операции без валидации путей ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/ipc/filesWork.ts:10-28` (`create-file`, `create-folder`) +- `app/main/ipc/filesWork.ts:85-109` (`remove-by-path`) +- `app/main/helpers/os.js:35-97` (`readDirTree`) + +**Описание:** +Все файловые IPC endpoint'ы принимают raw строку пути, запускают `path.resolve(targetPath)` и выполняют операцию. Никакой проверки, что путь находится внутри разрешённого workspace, не производится. + +**Почему это важно:** +Любой JavaScript в renderer (или скомпрометированное расширение) может создавать, удалять или читать файлы в любом месте, доступном пользователю ОС — включая `~/.ssh/`, системные директории или другие проекты. + +**Рекомендация:** +Реализовать guard на уровне корня workspace. Каждая файловая операция должна проверять, что разрезолвленный путь остаётся внутри текущей открытой папки проекта (или user-approved allow-list). + +> **Исправление:** В `app/main/ipc/filesWork.ts` добавлена функция `guardPath`, которая отклоняет пути, содержащие `..` или выходящие за пределы `process.cwd()`. Она применена к `create-file`, `create-folder` и `remove-by-path`. Для `read-file` добавлен отдельный guard: разрезолвленный путь должен оставаться внутри `parentPath`. + +--- + +### C-010 — JWT-токен хранится в plaintext ✅ ИСПРАВЛЕНО +**Место:** +- `app/auth.js:140-148` +- `app/main/helpers/paths.js:17` + +**Описание:** +`saveToken` записывает JWT напрямую в `local.json` в `app.getPath('userData')` без шифрования. Файл по умолчанию world-readable на многих системах. + +**Почему это важно:** +При получении атакующим доступа к профилю пользователя (malware, кража бэкапа и т.д.) сессионный токен сразу скомпрометирован. + +**Рекомендация:** +Шифровать токен ключом из системного хранилища учётных данных (`safeStorage` в современном Electron, `keytar` или `node-keytar`). + +> **Исправление:** В `app/auth.js` `saveToken` и `loadToken` переписаны с использованием `safeStorage` из Electron. Токен сериализуется в JSON, шифруется `safeStorage.encryptString` и записывается в файл. При загрузке `safeStorage.decryptString` расшифровывает буфер обратно. Реализован fallback на plaintext для legacy-файлов (начинающихся с `{`). + +--- + +## 🟠 High + +### H-001 — Утечка EventEmitter и Race Condition в системе уведомлений ✅ ИСПРАВЛЕНО +**Место:** +- `app/notifications/notifications.js:43` +- `app/notifications/notifications.js:105-108` + +**Описание:** +Каждый вызов `spawnNotification` выполняет: +```js +ipcMain.removeAllListeners("notification-close") +``` +Это **удаляет все listener'ы** на этом канале во всём приложении, затем добавляет один новый. Если несколько уведомлений живут одновременно, старые теряют свой close handler. Listener также никогда не удаляется при уничтожении окна. + +**Почему это важно:** +Проблема стабильности: уведомления могут стать незакрываемыми, а несвязанные IPC-каналы могут быть случайно очищены в будущем, если имя канала будет переиспользовано. + +**Рекомендация:** +Сохранять ссылку на listener и удалять только его при закрытии уведомления: +```js +const closeHandler = (event) => { ... }; +ipcMain.on("notification-close", closeHandler); +win.once("closed", () => ipcMain.removeListener("notification-close", closeHandler)); +``` + +> **Исправление:** В `app/notifications/notifications.js` убран `ipcMain.removeAllListeners("notification-close")`. Теперь каждое уведомление создаёт свой `closeHandler`, привязывает его к `notification-close`, и `win.once("closed")` удаляет только этот handler. Уведомления больше не конфликтуют. + +--- + +### H-002 — Race Condition в diagnostics worker (потерянные результаты) ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/tools/diagnostics.ts:40-77` + +**Описание:** +Существует один глобальный `pending.js` и `pending.ts` resolver. Если два diagnostic-запроса придут до ответа первого worker'а, второй **перезапишет** resolver. Первый вызывающий никогда не получит результат. + +**Почему это важно:** +В реальном редакторе пользователи часто быстро печатают, вызывая перекрывающиеся diagnostic-запросы. Безмолвная потеря данных приводит к сломанному UX и отсутствию error squiggles. + +**Рекомендация:** +Использовать очередь или request-ID map: +```js +const pending = new Map(); +let id = 0; +ipcMain.handle("javascript-diagnostic", async (_, code) => { + return new Promise((resolve) => { + const reqId = ++id; + pending.set(reqId, resolve); + workers.js.postMessage({ id: reqId, code }); + }); +}); +workers.js.on("message", ({ id, diagnostics }) => { + pending.get(id)?.(diagnostics); + pending.delete(id); +}); +``` + +> **Исправление:** В `app/main/tools/diagnostics.ts` заменён scalar `pending` на `Map` с автоинкрементным `id`. Worker'ы `diagnosticsJsWorker.js` и `diagnosticsTsWorker.js` теперь возвращают `{ id, diagnostics }`. Handler на стороне main process ищет resolver по `id` в Map. Параллельные запросы больше не перезаписывают друг друга. + +--- + +### H-003 — Окно отладчика накапливает IPC-listener'ы ✅ ИСПРАВЛЕНО +**Место:** +- `helpers/debuggerWindow/debuggerWindow.js:48-55` + +**Описание:** +При каждом создании debugger window регистрируется новый `ipcMain.on("debugger-data", ...)`. Старые listener'ы никогда не удаляются, поэтому каждое новое debug-событие отправляется **во все ранее созданные (и возможно уже уничтоженные) debugger window**. + +**Почему это важно:** +Утечка памяти + исключения при вызове `webContents.send` на уничтоженных окнах. + +**Рекомендация:** +Перенести регистрацию `ipcMain.on("debugger-data", ...)` на уровень модуля (единожды) и защитить `send` проверкой `!isDestroyed()`. + +> **Исправление:** В `helpers/debuggerWindow/debuggerWindow.js` listener `debugger-data` вынесен на уровень модуля (регистрируется единожды при загрузке). Добавлено явное объявление `let debuggerWindow = null` вместо неявной глобальной. `close-window` handler теперь проверяет `!debuggerWindow.isDestroyed()`. + +--- + +### H-004 — Splash Screen использует полный preload главного окна ✅ ИСПРАВЛЕНО +**Место:** +- `app/splash/splash.js:19-21` + +**Описание:** +Окно splash загружает тот же `preload.js`, что и главное окно редактора. Этот preload экспонирует **весь** `window.electron` API (сохранение файлов, терминал, выполнение Python, отладчик и т.д.). + +**Почему это важно:** +Если splash HTML когда-либо будет скомпрометирован (XSS, инъекция локального файла), атакующий получит полные привилегии main process. + +**Рекомендация:** +Создать **отдельный минимальный preload** для splash screen, экспонирующий только `close`, `setNonAccountMode`, `reload` и `onStatusUpdate`. + +> **Исправление:** Создан `app/splash/splash-preload.js` с минимальным API (`close`, `setNonAccountMode`, `reload`, `onStatusUpdate`). `app/splash/splash.js` теперь использует `splash-preload.js` вместо общего `PRELOAD_PATH`. Также добавлена проверка `!splash.isDestroyed()` в `updateSplash`. + +--- + +### H-005 — `updateLocalAppData` пишет в неправильный файл ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/helpers/requests.js:190-211` + +**Описание:** +```js +const filePath = path.join(__dirname, "local.json"); +``` +`__dirname` здесь разрешается в `app/main/helpers/`, поэтому файл пишется в `app/main/helpers/local.json`. Между тем `ensureLocalJson()` и `getLocalAppData()` используют `LOCAL_FILE_PATH`, который лежит в `app.getPath('userData')`. + +**Почему это важно:** +Приложение поддерживает **две независимые копии** локальных данных. Состояние токена / авторизации и флаг "non-account mode" могут расходиться, что приводит к циклам входа, устаревшим сессиям или логическим багам. + +**Рекомендация:** +Изменить `updateLocalAppData` на использование `LOCAL_FILE_PATH` (или того же пути, что использует `ensureLocalJson`). + +> **Исправление:** В `app/main/helpers/requests.js` `updateLocalAppData` теперь использует `LOCAL_FILE_PATH` вместо `path.join(__dirname, "local.json")`. Данные токена и non-account mode теперь хранятся в одном файле. + +--- + +### H-006 — Global Keyboard Listener никогда не очищается ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/main.ts:8` +- `app/main/main.ts:146-152` + +**Описание:** +`GlobalKeyboardListener` инстанцируется при загрузке модуля и никогда не уничтожается. Listener, добавленный внутри `createWindow`, проверяет `mainWindow.isFocused()`, но не защищён от `mainWindow.isDestroyed()`. + +**Почему это важно:** +1. Глобальный keylogger-подобный listener сохраняется даже после выхода из приложения в некоторых жизненных циклах Electron, из-за чего ОС может помечать бинарник как вредоносный (в README уже упоминается это!). +2. Вызов `.isFocused()` на уничтоженном окне может бросить исключение. + +**Рекомендация:** +Вызывать `v.removeAllListeners()` (или метод dispose библиотеки) в `app.on('before-quit', ...)` и `app.on('window-all-closed', ...)`. Также добавить guard: `mainWindow && !mainWindow.isDestroyed()`. + +> **Исправление:** В `app/main/main.ts` добавлено хранение ссылки на listener (`keyboardListener`). В `mainWindow.on("closed")`, `app.on('before-quit')` и `app.on('window-all-closed')` вызывается `v.removeListener(keyboardListener)` и обнуляется ссылка. Также добавлен guard `!mainWindow.isDestroyed()` перед `isFocused()`. + +--- + +### H-007 — `stdin.write` в терминале падает на не-строковом input +**Место:** +- `app/main/helpers/terminal.js:166` + +**Описание:** +```js +const inputWithNewline = input.endsWith('\n') ? input : input + '\n'; +``` +Если `input` не является строкой (например, `null`, `undefined` или объект от багового renderer), это бросает `TypeError` и крашит обработчик терминала. + +**Почему это важно:** +Терминал — пользовательская функция. Некорректное сообщение от скрипта или расширения может тихо убить активную терминальную сессию. + +**Рекомендация:** +```js +const str = String(input ?? ''); +const inputWithNewline = str.endsWith('\n') ? str : str + '\n'; +``` + +--- + +### H-008 — `getPython` multiple-resolve race ✅ ИСПРАВЛЕНО +**Место:** +- `helpers/getPython.js:4-44` + +**Описание:** +Функция перебирает `['python3','python','py']` и вызывает `resolve(...)` изнутри каждого `exec` callback. Если несколько команд успешны, `resolve` вызывается многократно. + +**Почему это важно:** +Хотя нативный Promise игнорирует resolve после первого, последующие `exec`-процессы остаются висячими (утечка процессов). На Windows это может породить лишние shell'ы. + +**Рекомендация:** +Использовать флаг (`let resolved = false`) и игнорировать/убивать последующие результаты. + +> **Исправление:** В `helpers/getPython.js` добавлен флаг `resolved = false`. При первом успешном `exec` флаг устанавливается в `true`, и последующие callback'и игнорируются (`if (resolved) return`). + +--- + +### H-009 — Preload отладчика экспонирует запуск расширений ✅ ИСПРАВЛЕНО +**Место:** +- `helpers/debuggerWindow/preload.js:8` + +**Описание:** +Preload debugger window экспонирует `runExtension(code, permissions, meta)` напрямую в renderer отладчика. Отладчик — это developer tool с REPL-подобным интерфейсом (`index.js`). + +**Почему это важно:** +Если окно отладчика скомпрометировано (например, через вредоносный вывод команды или XSS), атакующий может вызвать `runExtension` с произвольным кодом и полными permissions. + +**Рекомендация:** +Убрать `runExtension` из debugger preload, либо как минимум ограничить строгим whitelist'ом permissions и источников кода. + +> **Исправление:** Из `helpers/debuggerWindow/preload.js` полностью удалён метод `runExtension`. Отладчик больше не может запускать расширения. + +--- + +### H-010 — Расширение может закрыть всё приложение ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/window/close.js:4` + +**Описание:** +Permission `window.close` просто вызывает `app.quit()`. Любое расширение, имеющее этот permission, может принудительно закрыть IDE, убив всю несохранённую работу и фоновые процессы. + +**Почему это важно:** +Denial-of-Service от любого установленного расширения. + +**Рекомендация:** +Переименовать permission в `window.quit` и требовать явного подтверждения пользователя перед вызовом `app.quit()`. Либо разрешать закрывать только окна, созданные тем же расширением. + +> **Исправление:** В `app/sandbox/permissions/window/close.js` перед `app.quit()` добавлен `dialog.showMessageBoxSync` с кнопками "Quit" / "Cancel". Пользователь должен явно подтвердить выход. Расширение больше не может мгновенно убить IDE. + +--- + +### H-011 — `filesWork.ts` `read-file` Path Traversal через `filePath` +**Место:** +- `app/main/ipc/filesWork.ts:142-143` + +**Описание:** +```js +const data = await fs.promises.readFile(path.join(parentPath, filePath), "utf-8") +``` +`filePath` не санитизируется. Передача `../secret.txt` позволяет выйти за пределы `parentPath`. + +**Почему это важно:** +Любой код в renderer может читать произвольные файлы, манипулируя аргументом `filePath`. + +**Рекомендация:** +Разрезолвить и защитить: +```js +const target = path.resolve(path.join(parentPath, filePath)); +if (!target.startsWith(path.resolve(parentPath) + path.sep)) throw new Error('Traversal'); +``` + +--- + +### H-012 — `open-in-browser` без валидации URL ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/ipc/misc.ts:3-5` + +**Описание:** +`shell.openExternal(url)` вызывается с нулевой валидацией. Хотя обычно renderer контролирует это, скомпрометированный renderer или расширение может открыть `file://` URL, `javascript:` или вредоносные исполняемые файлы. + +**Почему это важно:** +Может быть использовано для открытия вредоносного локального файла или запуска внешнего обработчика приложения. + +**Рекомендация:** +Валидировать URL-схему (`http:` / `https:`) и блокировать `javascript:`, `data:`, `file:`. + +> **Исправление:** В `app/main/ipc/misc.ts` добавлена функция `isAllowedExternalUrl`, которая разрешает только `http:` и `https:`. При попытке открыть URL с другой схемой бросается ошибка. + +--- + +### H-013 — `set-non-account-mode` race / повреждение JSON ✅ ИСПРАВЛЕНО +**Место:** +- `app/auth.js:360-380` + +**Описание:** +Код читает `LOCAL_FILE_PATH`, мутирует объект и записывает обратно синхронно. Отсутствует блокировка файла. Конкурентные записи (например, быстрые переключения) могут повредить JSON. + +**Почему это важно:** +Повреждённый `local.json` не позволит приложению запуститься или войти в систему. + +**Рекомендация:** +Использовать атомарную запись (писать во временный файл, затем `fs.renameSync`). + +> **Исправление:** В `app/auth.js` `set-non-account-mode` теперь пишет во временный файл (`LOCAL_FILE_PATH + ".tmp"`), а затем атомарно переименовывает его в `LOCAL_FILE_PATH` через `fs.renameSync`. Это исключает повреждение JSON при конкурентных записях. + +--- + +### H-014 — `registerCommand` использует неверную переменную в сообщении об ошибке ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/commands/registerCommand.js:14` + +**Описание:** +```js +throw new Error(`... Example: ${data.name.replaceAll(/\s/g, "-")}`) +``` +Код ссылается на `data.name`, но реальная переменная в scope — `input.name`. Если `data.name` не определён, `replaceAll` бросается на `undefined`. + +**Почему это важно:** +Крэш при обработке ошибки скрывает реальное validation-сообщение от разработчика расширения. + +**Рекомендация:** +Изменить на `input.name.replaceAll(...)`. + +> **Исправление:** В `app/sandbox/permissions/commands/registerCommand.js` `data.name` исправлено на `input.name`. Сообщение об ошибке теперь корректно использует переменную из scope. + +--- + +## 🟡 Medium + +### M-001 — `updateSplash` без проверки `isDestroyed` ✅ ИСПРАВЛЕНО +**Место:** +- `app/splash/splash.js:30-33` + +**Описание:** +`updateSplash` отправляет сообщение в `splash.webContents` без проверки `splash.isDestroyed()`. Если splash-окно было закрыто раньше, это бросает исключение. + +**Рекомендация:** +```js +if (splash && !splash.isDestroyed()) { + splash.webContents.send(...); +} +``` + +> **Исправление:** В `app/splash/splash.js` `updateSplash` теперь проверяет `!splash.isDestroyed()` перед отправкой сообщения. + +--- + +### M-002 — `handleOutput` в терминале без проверки `isDestroyed` ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/helpers/terminal.js:46` + +**Описание:** +`event.sender.send(...)` вызывается без проверки, существует ли отправляющее окно. + +**Рекомендация:** +```js +if (!event.sender.isDestroyed()) { + event.sender.send("terminal-result", ...); +} +``` + +> **Исправление:** В `app/main/helpers/terminal.js` `handleOutput` теперь обёрнут в `if (!event.sender.isDestroyed())` перед вызовом `send`. + +--- + +### M-003 — Live Server на жёстких портах (нет проверки коллизий) ✅ ИСПРАВЛЕНО +**Место:** +- `app/electron/live-server.js:22-23` + +**Описание:** +Порты `3000` и `3001` захардкожены. Если любой из них занят, сервер бросает необработанное исключение. + +**Рекомендация:** +Использовать `0` (назначение ОС) и сообщать реальный порт обратно в renderer, либо сканировать доступный порт перед биндингом. + +> **Исправление:** В `app/electron/live-server.js` реализована функция `tryListen`, которая пытается биндиться на порт 3000, а при `EADDRINUSE` инкрементирует порт до 10 попыток. Реальный порт возвращается в результате `ipcMain.handle`. + +--- + +### M-004 — Live Server не выставляет MIME-типы ✅ ИСПРАВЛЕНО +**Место:** +- `app/electron/live-server.js:50` + +**Описание:** +`res.writeHead(200)` отправляется без заголовка `Content-Type`. Браузеры могут отказаться выполнять `.js` или `.css` файлы. + +**Рекомендация:** +Использовать простую MIME-мапу на основе расширения файла. + +> **Исправление:** В `app/electron/live-server.js` добавлена функция `getMimeType`, которая возвращает `Content-Type` на основе `path.extname` для распространённых форматов. + +--- + +### M-005 — Live Server: хрупкая HTML-инъекция ✅ ИСПРАВЛЕНО +**Место:** +- `app/electron/live-server.js:32` + +**Описание:** +```js +return html.replace("", script + "") +``` +Если `` встречается внутри комментария или строки до реального тега, скрипт инжектируется в неправильное место. + +**Рекомендация:** +Использовать case-insensitive regex, нацеленный на последний ``, либо парсить HTML лёгким парсером. + +> **Исправление:** В `app/electron/live-server.js` `html.replace("", ...)` заменён на `html.replace(/<\/body>/i, ...)` — case-insensitive regex. + +--- + +### M-006 — `notification.html` отсутствует Content-Security-Policy ✅ ИСПРАВЛЕНО +**Место:** +- `html/notification.html` + +**Описание:** +Окно уведомления не имеет CSP-заголовка. Если вредоносное расширение инжектирует `javascript:` URL в изображение/иконку уведомления, оно может выполниться в renderer уведомления. + +**Рекомендация:** +Добавить строгий CSP, запрещающий inline-скрипты и ограничивающий `img-src` / `media-src`. + +> **Исправление:** В `html/notification.html` добавлен `` с правилами `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:`. + +--- + +### M-007 — Notification Renderer не санитизирует `src` изображения ✅ ИСПРАВЛЕНО +**Место:** +- `app/notifications/renderer.js:23` + +**Описание:** +```js +img.src = image +``` +`image` приходит напрямую из данных уведомления. `javascript:` pseudo-protocol теоретически может выполниться в некоторых renderer-контекстах. + +**Рекомендация:** +Валидировать, что `image` начинается с `http:`, `https:`, `data:` или `file:` перед присваиванием. + +> **Исправление:** В `app/notifications/renderer.js` добавлена проверка `/^https?:\/\//.test(String(image))` перед созданием ``. + +--- + +### M-008 — Extension `onFileOpened` слушает глобально без фильтра по sender ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/permissions/events/onFileOpened.js:6` + +**Описание:** +Каждое расширение с этим permission получает **все** сообщения `file-opened-event`, независимо от того, какое расширение или renderer их отправило. + +**Рекомендация:** +Отслеживать ID запрашивающего расширения и пересылать события только ему. + +> **Исправление:** В `app/sandbox/permissions/events/onFileOpened.js` добавлен `Map` `fileOpenedHandlers`, который хранит handler по `extensionName`. При новой регистрации старый handler удаляется через `ipcMain.removeListener`. Это предотвращает накопление listener'ов и ограничивает события рамками конкретного расширения. + +--- + +### M-009 — Sandbox `Object.freeze(app)` неглубокий ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/sandbox.js:195` + +**Описание:** +`Object.freeze(app)` замораживает только свойства верхнего уровня. Вложенные объекты вроде `app.permissions` (массив) и `app.CSSVariables` (массив) остаются изменяемыми. + +**Рекомендация:** +Выполнять deep freeze, либо строить API-объект как плоскую структуру замороженных функций. + +> **Исправление:** В `app/sandbox/sandbox.js` перед `Object.freeze(app)` добавлена рекурсивная функция `deepFreeze`, которая обходит все вложенные объекты и замораживает их. + +--- + +### M-010 — Sandbox error stack раскрывает исходный код расширения ✅ ИСПРАВЛЕНО +**Место:** +- `app/sandbox/sandbox.js:218` + +**Описание:** +Обработчик ошибок захватывает `err.stack` и возвращает его. Stack trace содержит строки инжектированного кода расширения, который может включать проприетарную логику или секреты. + +**Рекомендация:** +Очищать stack trace: удалять строки из `evalmachine.` и заменять их на общее сообщение. + +> **Исправление:** В `app/sandbox/sandbox.js` в блоке `catch` stack trace прогоняется через `split('\n').filter(line => !line.includes('evalmachine.')).join('\n')`. Строки `evalmachine.` полностью удаляются из возвращаемого сообщения. + +--- + +### M-011 — `loginById` определён, но не экспонирован через IPC ✅ ИСПРАВЛЕНО +**Место:** +- `app/auth.js:82-114` + +**Описание:** +Функция `loginById` реализована, но отсутствует соответствующий `ipcMain.handle('login-by-id', ...)`. + +**Рекомендация:** +Либо экспортировать, либо удалить мёртвый код. + +> **Исправление:** Обработчик `ipcMain.handle('login-by-id', ...)` уже присутствует в `app/auth.js`, поэтому этот пункт был ложным срабатыванием. Отмечено как исправлено/актуально. + +--- + +### M-012 — `getAppIcon` объявлен как `async` без `await` ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/helpers/requests.js:146-164` + +**Описание:** +Функция объявлена `async`, но все операции внутри синхронные. Это добавляет лишний оверхед Promise. + +**Рекомендация:** +Убрать ключевое слово `async`. + +> **Исправление:** В `app/main/helpers/requests.js` убрано `async` у `getAppIcon`. Во всех вызывающих местах (`main.ts`, `splash.js`, `debuggerWindow.js`, `getters.ts`) убран `await`. + +--- + +## 🟢 Low + +### L-001 — Вводящее в заблуждение имя переменной `isPackaged` ✅ ИСПРАВЛЕНО +**Место:** +- `app/main/main.ts:81` + +**Описание:** +```js +const isPackaged = !app.isPackaged; +``` +Переменная называется `isPackaged`, но фактически содержит **обратное** значение (dev mode). Используется корректно (`frame: dev`), но имя крайне запутанное. + +**Рекомендация:** +Переименовать в `isDev` или `devMode`. + +> **Исправление:** В `app/main/main.ts` переменная `isPackaged` удалена (она была мёртвым кодом и не использовалась). Логика dev-режима управляется отдельной переменной `dev`. + +--- + +### L-002 — Утечка глобальной переменной `debuggerWindow` ✅ ИСПРАВЛЕНО +**Место:** +- `helpers/debuggerWindow/debuggerWindow.js:37` + +**Описание:** +```js +debuggerWindow = win +``` +`debuggerWindow` нигде не объявлена через `let`/`const`/`var`, поэтому становится неявной глобальной переменной. + +**Рекомендация:** +Объявить `let debuggerWindow = null` в начале файла. + +> **Исправление:** Уже исправлено в рамках H-003 — `let debuggerWindow = null` добавлен в `helpers/debuggerWindow/debuggerWindow.js`. + +--- + +### L-003 — Тег `