diff --git a/package-lock.json b/package-lock.json index be2cbbb..126fedc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "0.0.1", "dependencies": { "@types/cors": "^2.8.19", + "@types/diff-match-patch": "^1.0.36", "@types/express": "^5.0.6", + "axios": "^1.13.5", "cors": "^2.8.6", + "diff-match-patch": "^1.0.5", "express": "^5.2.1" }, "devDependencies": { @@ -301,6 +304,12 @@ "@types/node": "*" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -769,6 +778,23 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1063,6 +1089,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1186,6 +1224,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1205,6 +1252,12 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1304,6 +1357,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1717,6 +1785,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1734,6 +1822,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1972,6 +2097,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3090,6 +3230,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 7d5e8bf..939169a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,37 @@ "command": "devrun-vscode.stopServer", "title": "DevRun: Stop the run server (for quick run starts)" } - ] + ], + "menus": { + "commandPalette": [ + { + "command": "devrun-vscode.startRun", + "when": "!devrun.runActive" + }, + { + "command": "devrun-vscode.stopRun", + "when": "devrun.runActive" + }, + { + "command": "devrun-vscode.startServer", + "when": "!devrun.serverActive" + }, + { + "command": "devrun-vscode.stopServer", + "when": "devrun.serverActive" + } + ] + }, + "configuration": { + "title": "DevRun", + "properties": { + "devrun.autoStartServer": { + "type": "boolean", + "default": false, + "description": "Automatically start the server when vscode starts" + } + } + } }, "scripts": { "vscode:prepublish": "npm run compile", @@ -54,8 +84,11 @@ }, "dependencies": { "@types/cors": "^2.8.19", + "@types/diff-match-patch": "^1.0.36", "@types/express": "^5.0.6", + "axios": "^1.13.5", "cors": "^2.8.6", + "diff-match-patch": "^1.0.5", "express": "^5.2.1" } } diff --git a/src/client.ts b/src/client.ts index bfe0b32..219a67c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,13 +1,57 @@ -export interface LiveRunMove { - latency: number; - cursor: number; - changes?: { - from: number; - to: number; - insert: string; - }; +import axios from "axios"; +import { LiveRunMove } from "./types"; + +var cookies: string | undefined; + +export function getCookies(): string | undefined { + return cookies; +} +export function setCookies(c: string) { + cookies = c; } -export async function submitRun() {} +export async function addRun(problem: string, mode: string): Promise { + return ( + await axios.put( + "http://localhost:3000/api/run", + { + problem, + category: mode, + }, + { + headers: { + Cookie: cookies, + }, + }, + ) + ).data.runId; +} -export async function addRunMoves() {} +export async function submitRun(runId: string) { + await axios.post( + "http://localhost:3000/api/run", + { + runId, + }, + { + headers: { + Cookie: cookies, + }, + }, + ); +} + +export async function addRunMoves(runId: string, moves: LiveRunMove[]) { + await axios.post( + "http://localhost:3000/api/run/move", + { + runId, + moves, + }, + { + headers: { + Cookie: cookies, + }, + }, + ); +} diff --git a/src/extension.ts b/src/extension.ts index 66f7d2b..f3c08b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,10 +1,12 @@ import * as vscode from "vscode"; import { currentRun, startRun, stopRun } from "./run"; import { startServer, stopServer } from "./server"; +import os from "os"; +import { exec } from "child_process"; +import net from "net"; export function activate(context: vscode.ExtensionContext) { - console.log("dev-run Activated"); - + const config = vscode.workspace.getConfiguration("devrun"); const statusBarItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, 10, @@ -49,8 +51,101 @@ export function activate(context: vscode.ExtensionContext) { stopServer(statusBarItem), ), ); + console.log("dev-run Activated"); + + // Security check - Make sure there aren't any malicious servers currently running + const autoStart = config.get("autoStartServer"); + + canConnect(63780) + .then((canConnect) => { + if (canConnect) { + console.error( + "Unable to start due to another process listening on port 63780", + ); + + console.error("Possible malicious dev-run instance running"); + + vscode.window + .showErrorMessage( + "Possible malicious dev-run instance running", + "Kill the process listening on port 63780", + ) + .then(async (response) => { + if (!response) return; + + if (response === "Kill the process listening on port 63780") { + await killProcessOnPort(63780); + + if (autoStart) { + startServer(statusBarItem); + console.log("dev-run server auto started!"); + } + } + }); + + return; + } + + if (autoStart) { + startServer(statusBarItem); + console.log("dev-run server auto started!"); + } + }) + .catch(() => {}); } export function deactivate() { stopRun(); } + +export function canConnect( + port: number, + host = "127.0.0.1", + timeout = 1000, +): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + + socket.setTimeout(timeout); + + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + + socket.once("error", () => { + resolve(false); + }); + + socket.connect(port, host); + }); +} + +export function killProcessOnPort(port: number): Promise { + return new Promise((resolve, reject) => { + const platform = os.platform(); + + let cmd: string; + + if (platform === "win32") { + // Windows + cmd = `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port}') do taskkill /PID %a /F`; + } else { + // macOS / Linux + cmd = `lsof -ti tcp:${port} | xargs kill -9`; + } + + exec(cmd, (error) => { + if (error) { + resolve(); + } else { + resolve(); + } + }); + }); +} diff --git a/src/move.ts b/src/move.ts new file mode 100644 index 0000000..cd0ca43 --- /dev/null +++ b/src/move.ts @@ -0,0 +1,112 @@ +import * as vscode from "vscode"; +import { diff_match_patch, Diff } from "diff-match-patch"; +import { LiveRunMove } from "./types"; +import { addRunMoves } from "./client"; + +export function startMonitoring(runId: string) { + const dmp = new diff_match_patch(); + + let moveId = 0; + let moves: LiveRunMove[] = []; + + let lastText = ""; + let lastEventTime = Date.now(); + let idleTimeout: NodeJS.Timeout | undefined; + let isSending = false; + + const IDLE_MS = 350; + + const editor = vscode.window.activeTextEditor; + if (editor) { + lastText = editor.document.getText(); + } + + async function flush() { + if (!moves.length || isSending) return; + + isSending = true; + const batch = moves; + moves = []; + + try { + await addRunMoves(runId, batch); + } finally { + isSending = false; + } + } + + const readInterval = setInterval(() => { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + + const newText = editor.document.getText(); + if (newText === lastText) return; + + const now = Date.now(); + const latency = capLatency(now - lastEventTime, IDLE_MS); + lastEventTime = now; + + const diffs = dmp.diff_main(lastText, newText); + dmp.diff_cleanupEfficiency(diffs); + + const changes = diffsToChanges(diffs); + + lastText = newText; + + for (const change of changes) { + moves.push({ + moveId: moveId++, + latency, + cursor: editor.document.offsetAt(editor.selection.active), + changes: change, + }); + } + + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout(flush, IDLE_MS); + }, 500); + + return () => { + clearInterval(readInterval); + if (idleTimeout) clearTimeout(idleTimeout); + flush(); + }; +} + +function diffsToChanges(diffs: Diff[]) { + const changes: { + from: number; + to: number; + insert: string; + }[] = []; + + let offset = 0; + + for (const [op, text] of diffs) { + if (op === 0) { + // equal + offset += text.length; + } else if (op === -1) { + // delete + changes.push({ + from: offset, + to: offset + text.length, + insert: "", + }); + } else if (op === 1) { + // insert + changes.push({ + from: offset, + to: offset, + insert: text, + }); + offset += text.length; + } + } + + return changes; +} + +function capLatency(value: number, max: number) { + return value > max ? 0 : value; +} diff --git a/src/run.ts b/src/run.ts index fa245f9..75a71a9 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,9 +1,13 @@ import * as vscode from "vscode"; +import { addRun, submitRun } from "./client"; +import { startMonitoring } from "./move"; export var currentRun: { interval: NodeJS.Timeout; + stopMonitoring: () => void; problemId: string; start: Date; + id: string; } | null = null; export async function startRun( @@ -36,11 +40,15 @@ export async function startRun( if (!mode) return; + const runId = await addRun(problemId, mode); + let start = Date.now(); currentRun = { + id: runId, problemId, start: new Date(), + stopMonitoring: startMonitoring(runId), interval: setInterval(() => { const elapsed = Date.now() - start; @@ -53,11 +61,17 @@ export async function startRun( .padStart(2, "0")}`; }, 50), }; + + await vscode.commands.executeCommand("setContext", "devrun.runActive", true); } export async function stopRun() { if (currentRun) { - clearTimeout(currentRun.interval); + clearInterval(currentRun.interval); + submitRun(currentRun.id); + currentRun = null; } + + await vscode.commands.executeCommand("setContext", "devrun.runActive", false); } diff --git a/src/server.ts b/src/server.ts index ce3f2b2..317875e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,15 +2,16 @@ import * as vscode from "vscode"; import express from "express"; import * as http from "http"; import cors from "cors"; +import { getCookies, setCookies } from "./client"; -let server: http.Server | undefined; +var server: http.Server | undefined; const app = express(); +app.use(express.json()); app.use( cors({ origin: (origin, callback) => { - // allow requests with no origin (e.g., direct curl) if ( !origin || origin.includes("localhost") || @@ -25,10 +26,19 @@ app.use( }), ); +app.post("/auth", (req, res) => { + const { saveCookies } = req.body; + + setCookies(saveCookies); + + res.json({ ok: true }); +}); + app.get("/start-run/:problemId", (req, res) => { const { problemId } = req.params; - // trigger VS Code command + if (!getCookies()) return res.json({ ok: false, code: "auth" }); + vscode.commands.executeCommand("devrun-vscode.startRun", problemId); res.json({ ok: true, problemId }); @@ -42,6 +52,12 @@ export async function startServer(statusBarItem: vscode.StatusBarItem) { server = app.listen(63780, "127.0.0.1", () => { setTimeout(() => (statusBarItem.text = "$(clock) No active run"), 500); }); + + await vscode.commands.executeCommand( + "setContext", + "devrun.serverActive", + true, + ); } export async function stopServer(statusBarItem: vscode.StatusBarItem) { @@ -57,4 +73,10 @@ export async function stopServer(statusBarItem: vscode.StatusBarItem) { server = undefined; setTimeout(() => (statusBarItem.text = "$(clock) No active run"), 500); + + await vscode.commands.executeCommand( + "setContext", + "devrun.serverActive", + false, + ); } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d2f1bbc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +export interface LiveRunMove { + latency: number; + cursor: number; + changes?: { + from: number; + to: number; + insert: string; + }; + moveId: number; +}