diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c89f23b..5bacdf9d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,11 @@ "material-icon-theme.folders.associations": { "shapes": "middleware" }, - "cSpell.words": [], + "material-icon-theme.files.associations": { + "vite.config.chrome.ts": "vite", + "vite.config.firefox.ts": "vite" + }, + "cSpell.words": ["posthog", "Resolvables"], "todo-tree.general.tags": ["=====", "NOTE", "IDEA", "TODO", "FIX", "HACK"], "todo-tree.highlights.customHighlight": { "=====": { diff --git a/apps/browser-extension/.postcssrc.json b/apps/browser-extension/.postcssrc.json deleted file mode 100644 index 380d6768..00000000 --- a/apps/browser-extension/.postcssrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plugins": { - "tailwindcss": {}, - "autoprefixer": {} - } -} diff --git a/apps/browser-extension/manifest.json b/apps/browser-extension/manifest.json new file mode 100644 index 00000000..6c52ba4b --- /dev/null +++ b/apps/browser-extension/manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest", + + "manifest_version": 3, + "version": "0.2.0", + "author": { + "name": "Apteryx Software", + "email": "kiaora@apteryx.xyz" + }, + "name": "Evaluate - Execute Any Code, Anywhere, Anytime", + "short_name": "Evaluate", + "description": "Execute any code snippet on any website with Evaluate.", + "homepage_url": "https://evaluate.run", + + "icons": { + "16": "images/icon/16.png", + "32": "images/icon/32.png", + "48": "images/icon/48.png", + "64": "images/icon/64.png", + "128": "images/icon/128.png" + }, + "action": { + "default_icon": { + "16": "images/icon/16.png", + "32": "images/icon/32.png", + "48": "images/icon/48.png", + "64": "images/icon/64.png", + "128": "images/icon/128.png" + } + }, + + "permissions": ["contextMenus"], + "background": { + "service_worker": "src/background/index.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["src/content-script/index.tsx"] + } + ], + "web_accessible_resources": [ + { + "matches": [""], + "resources": ["images/icon.png"], + "use_dynamic_url": false + } + ] +} diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index 1e7b00dc..493f3584 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -1,44 +1,47 @@ { "name": "browser-extension", - "version": "0.1.3", + "version": "0.2.0", "type": "module", - "manifest": { - "name": "Evaluate - Execute Any Code, Anywhere, Anytime", - "short_name": "Evaluate", - "description": "Execute any code snippet on any website with Evaluate.", - "author": "Apteryx Software", - "homepage_url": "https://evaluate.run", - "manifest_version": 3, - "permissions": ["contextMenus", "storage"] - }, "scripts": { "check": "tsc --noEmit", - "dev": "use-env -p PLASMO -- plasmo dev", - "build:styles": "cp ../../packages/react/style.css ./src/style.css", - "build": "pnpm build:chrome", - "build:chrome": "use-env -p PLASMO -P -- plasmo build --target=chrome-mv3 --zip" + "build": "pnpm run \"/build:.*/\"", + "build:chrome": "use-env -p VITE -P -- vite build --config vite.config.chrome.ts", + "build:firefox": "use-env -p VITE -P -- vite build --config vite.config.firefox.ts", + "dev": "pnpm run \"/dev:.*/\"", + "dev:chrome": "use-env -p VITE -- vite --config vite.config.chrome.ts", + "dev:firefox": "use-env -p VITE -- vite --config vite.config.firefox.ts" }, "dependencies": { "@evaluate/components": "workspace:^", "@evaluate/engine": "workspace:^", "@evaluate/helpers": "workspace:^", - "@evaluate/hooks": "workspace:^", "@evaluate/shapes": "workspace:^", "@evaluate/style": "workspace:^", "@t3-oss/env-core": "^0.11.1", - "framer-motion": "^11.12.0", - "lucide-react": "^0.338.0", - "posthog-js": "1.161.3", + "lucide-react": "^0.475.0", + "posthog-js": "^1.218.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "webext-bridge": "^6.0.1", + "webextension-polyfill": "^0.12.0", "zod": "3.22.4" }, "devDependencies": { - "@types/chrome": "^0.0.263", - "@types/react": "^18.3.12", - "autoprefixer": "^10.4.20", - "plasmo": "^0.85.2", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.15" + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "@crxjs/vite-plugin": "2.0.0-beta.30", + "@types/babel__generator": "^7.6.8", + "@types/babel__traverse": "^7.20.6", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/webextension-polyfill": "^0.12.1", + "@vitejs/plugin-react": "^4.3.4", + "vite": "3.2.11", + "vite-plugin-zip-pack": "^1.2.4", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/apps/browser-extension/postcss.config.cjs b/apps/browser-extension/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/apps/browser-extension/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/browser-extension/assets/icon.png b/apps/browser-extension/public/images/icon.png similarity index 100% rename from apps/browser-extension/assets/icon.png rename to apps/browser-extension/public/images/icon.png diff --git a/apps/browser-extension/public/images/icon/128.png b/apps/browser-extension/public/images/icon/128.png new file mode 100644 index 00000000..a43cc0b1 Binary files /dev/null and b/apps/browser-extension/public/images/icon/128.png differ diff --git a/apps/browser-extension/public/images/icon/16.png b/apps/browser-extension/public/images/icon/16.png new file mode 100644 index 00000000..cbe56f58 Binary files /dev/null and b/apps/browser-extension/public/images/icon/16.png differ diff --git a/apps/browser-extension/public/images/icon/180.png b/apps/browser-extension/public/images/icon/180.png new file mode 100644 index 00000000..de386528 Binary files /dev/null and b/apps/browser-extension/public/images/icon/180.png differ diff --git a/apps/browser-extension/public/images/icon/192.png b/apps/browser-extension/public/images/icon/192.png new file mode 100644 index 00000000..7bc18195 Binary files /dev/null and b/apps/browser-extension/public/images/icon/192.png differ diff --git a/apps/browser-extension/public/images/icon/32.png b/apps/browser-extension/public/images/icon/32.png new file mode 100644 index 00000000..ffd6b0ee Binary files /dev/null and b/apps/browser-extension/public/images/icon/32.png differ diff --git a/apps/browser-extension/public/images/icon/48.png b/apps/browser-extension/public/images/icon/48.png new file mode 100644 index 00000000..9f2c5c26 Binary files /dev/null and b/apps/browser-extension/public/images/icon/48.png differ diff --git a/apps/browser-extension/public/images/icon/512.png b/apps/browser-extension/public/images/icon/512.png new file mode 100644 index 00000000..87ced94e Binary files /dev/null and b/apps/browser-extension/public/images/icon/512.png differ diff --git a/apps/browser-extension/public/images/icon/64.png b/apps/browser-extension/public/images/icon/64.png new file mode 100644 index 00000000..62831629 Binary files /dev/null and b/apps/browser-extension/public/images/icon/64.png differ diff --git a/apps/browser-extension/src/background/index.ts b/apps/browser-extension/src/background/index.ts index c2c6744f..b1b5e936 100644 --- a/apps/browser-extension/src/background/index.ts +++ b/apps/browser-extension/src/background/index.ts @@ -1,124 +1,104 @@ -import { executeCode } from '@evaluate/engine/dist/execute'; -import { searchRuntimes } from '@evaluate/engine/dist/runtimes'; -import type { PartialRuntime } from '@evaluate/shapes'; -import env from '~env'; -import analytics from '~services/analytics'; +import { executeCode } from '@evaluate/engine/execute'; +import { searchRuntimes } from '@evaluate/engine/runtimes'; +import type { ExecuteResult, PartialRuntime } from '@evaluate/shapes'; +import type { ProtocolWithReturn } from 'webext-bridge'; +import { onMessage, sendMessage } from 'webext-bridge/background'; +import browser from 'webextension-polyfill'; +import env from '~/env'; +import posthog, { sessionLog } from '~/services/posthog'; + +declare module 'webext-bridge' { + export interface ProtocolMap { + getSelectionInfo: ProtocolWithReturn< + void, + { code: string; resolvables: string[] } + >; + unknownRuntime: ProtocolWithReturn<{ code: string }, void>; + executionStarted: ProtocolWithReturn< + { runtimeNameOrCount: string | number }, + void + >; + executionFailed: ProtocolWithReturn<{ errorMessage: string }, void>; + executionFinished: ProtocolWithReturn< + { code: string; runtimes: PartialRuntime[]; results: ExecuteResult[] }, + void + >; + getBackgroundSessionId: ProtocolWithReturn; + } +} -chrome.action.setTitle({ title: 'Evaluate' }); +browser.action.setTitle({ title: 'Evaluate' }); -chrome.runtime.onInstalled.addListener(async (details) => { - if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { - analytics?.capture('extension installed', { - $current_url: '', - platform: 'browser extension', - }); - } else if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) { - analytics?.capture('extension updated', { - $current_url: '', - platform: 'browser extension', - }); +browser.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + posthog?.capture('installed_extension'); + } else if (details.reason === 'update') { + posthog?.capture('updated_extension'); } - - chrome.contextMenus.create({ - id: 'runCodeSelection', - title: 'Execute Code', - contexts: ['selection'], - }); }); -chrome.action.onClicked.addListener(async () => { - analytics?.capture('browser action clicked', { - $current_url: '', - platform: 'browser extension', - }); - - chrome.tabs.create({ - url: `${env.PLASMO_PUBLIC_WEBSITE_URL}`, - }); +browser.contextMenus.create({ + id: 'runCodeSelection', + title: 'Execute Code', + contexts: ['selection'], }); -chrome.contextMenus.onClicked.addListener(async (info, tab) => { - if (info.menuItemId === 'runCodeSelection' && tab?.id) { - analytics?.capture('context menu item clicked', { - $current_url: tab?.url || '', - platform: 'browser extension', - }); - - // While the info.selectionText is available, we need the element - // to extract the runtime resolvables, so we send a message to the - // messaging content script to get the runtime resolvables - chrome.tabs.sendMessage(tab.id, { - subject: 'parseSelection', - }); - } +browser.action.onClicked.addListener(async () => { + console.log(posthog); + posthog?.capture('clicked_browser_action'); + browser.tabs.create({ url: `${env.VITE_PUBLIC_WEBSITE_URL}` }); }); -chrome.runtime.onMessage.addListener(async (message, sender) => { - if (typeof sender.tab?.id !== 'number') return; - - if (message.relay === true) { - chrome.tabs.sendMessage(sender.tab.id, message); - return; - } - - if (message.subject === 'prepareCode') { - const code = message.code as string; - const runtimeResolvables = message.runtimeResolvables as string[]; - - const runtimes = await searchRuntimes(...runtimeResolvables) // - .then((r) => r.slice(0, 5)); - - if (runtimes.length) { - message = { - subject: 'runCode', - ...{ code, runtimes }, - }; - } else { - chrome.tabs.sendMessage(sender.tab.id, { - subject: 'unknownRuntime', - ...{ code }, +browser.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId !== 'runCodeSelection' || !tab?.id) return; + + posthog?.capture('clicked_context_menu_item', { $current_url: tab.url }); + const endpoint = `content-script@${tab.id}`; + + const selection = await sendMessage('getSelectionInfo', void 0, endpoint); + const { code, resolvables } = selection; + const runtimes = (await searchRuntimes(...resolvables)).slice(0, 5); + if (!runtimes.length) + return sendMessage('unknownRuntime', { code }, endpoint); + + const runtimeNameOrCount = + runtimes.length === 1 ? runtimes[0]!.name : runtimes.length; + sendMessage('executionStarted', { runtimeNameOrCount }, endpoint); + + const promises = []; + for (const runtime of runtimes) { + const initialPromise = executeCode({ + runtime: runtime.id, + files: { 'file.code': code }, + entry: 'file.code', + }) + .then((result) => { + posthog?.capture('executed_code', { + runtime_id: runtime.id, + code_length: code.length, + code_lines: code.split('\n').length, + compile_successful: result.compile ? result.compile.code === 0 : null, + execution_successful: + result.run.code === 0 && + (!result.compile || result.compile.code === 0), + }); + return result; + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : 'Unknown error'; + sendMessage('executionFailed', { errorMessage: message }, endpoint); + throw error; }); - } + promises.push(initialPromise); } - // - - if (message.subject === 'runCode') { - const code = message.code as string; - const runtimes = message.runtimes as PartialRuntime[]; - - chrome.tabs.sendMessage(sender.tab.id, { - subject: 'executionStarted', - ...{ runtimes }, - }); - - const promises = []; - for (const runtime of runtimes) { - promises.push( - executeCode({ - runtime: runtime.id, - files: { 'file.code': code }, - entry: 'file.code', - }).then(async (r) => { - analytics?.capture('code executed', { - $current_url: sender.tab?.url || '', - platform: 'browser extension', - 'runtime id': runtime.id, - 'was successful': - r.run.code === 0 && (!r.compile || r.compile.code === 0), - }); - return { ...r, runtime }; - }), - ); - - if (runtimes.length > 1) - await new Promise((resolve) => setTimeout(resolve, 500)); - } + const results = await Promise.all(promises); + sendMessage('executionFinished', { code, runtimes, results }, endpoint); +}); - const results = await Promise.all(promises); - chrome.tabs.sendMessage(sender.tab.id, { - subject: 'showResults', - ...{ code, results }, - }); - } +onMessage('getBackgroundSessionId', () => { + const sessionId = posthog?.get_session_id(); + sessionLog(sessionId); + return sessionId; }); diff --git a/apps/browser-extension/src/content-script/execution/dialog.tsx b/apps/browser-extension/src/content-script/execution/dialog.tsx new file mode 100644 index 00000000..f3fe617e --- /dev/null +++ b/apps/browser-extension/src/content-script/execution/dialog.tsx @@ -0,0 +1,81 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from '@evaluate/components/dialog'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@evaluate/components/tabs'; +import type { ExecuteResult, PartialRuntime } from '@evaluate/shapes'; +import browser from 'webextension-polyfill'; +import env from '~/env'; +import { ResultDialog } from './result'; + +export function ExecutionDialog({ + portal, + code, + runtimes, + results, + setResults, +}: { + portal: HTMLElement; + code: string; + runtimes: PartialRuntime[]; + results: ExecuteResult[]; + setResults: (results: ExecuteResult[]) => void; +}) { + const open = results.length > 0; + const onClose = () => setResults([]); + const successIndex = results.findIndex((r) => r.run?.code === 0); + + return ( + + + + Evaluate logo + + + Evaluate + + + + + + + + {runtimes.map((runtime) => ( + + {runtime.name} + + ))} + + + {runtimes.map((runtime, i) => ( + + + + ))} + + + + + ); +} diff --git a/apps/browser-extension/src/content-script/execution/index.tsx b/apps/browser-extension/src/content-script/execution/index.tsx new file mode 100644 index 00000000..fa27e7b7 --- /dev/null +++ b/apps/browser-extension/src/content-script/execution/index.tsx @@ -0,0 +1,89 @@ +import { toast } from '@evaluate/components/toast'; +import type { ExecuteResult, PartialRuntime } from '@evaluate/shapes'; +import { useEffect, useState } from 'react'; +import { onMessage } from 'webext-bridge/content-script'; +import { makePickRuntimeUrl } from '~/helpers/make-url'; +import { ExecutionDialog } from './dialog'; + +export function Execution({ + dialogPortal, +}: { + dialogPortal: HTMLElement; +}) { + const [code, setCode] = useState(''); + const [runtimes, setRuntimes] = useState([]); + const [results, setResults] = useState([]); + + useEffect(() => { + let lastToastId: string | number; + + const removeUnknownRuntimeListener = onMessage( + 'unknownRuntime', + ({ data: { code } }) => { + const pickUrl = makePickRuntimeUrl(code); + toast.error('Could not determine runtime', { + description: + 'Evaluate was unable to determine a runtime for the selected text.', + action: { + label: 'Pick a Runtime', + onClick: () => window.open(pickUrl, '_blank'), + }, + }); + }, + ); + + const removeExecutionStartedListener = onMessage( + 'executionStarted', + ({ data: { runtimeNameOrCount } }) => { + const description = + typeof runtimeNameOrCount === 'number' + ? // Number is always greater than 1 + `Running in ${runtimeNameOrCount} runtimes for the best results.` + : `Running in ${runtimeNameOrCount}.`; + const toastId = toast.loading( + 'Executing code, this may take a few seconds...', + { description }, + ); + lastToastId = toastId; + setTimeout(() => toast.dismiss(toastId), 15000); + }, + ); + + const removeExecutionFailedListener = onMessage( + 'executionFailed', + ({ data: { errorMessage } }) => { + toast.dismiss(lastToastId); + toast.error('Execution could not be completed', { + description: errorMessage, + }); + }, + ); + + const removeExecutionFinishedListener = onMessage( + 'executionFinished', + ({ data: { code, runtimes, results } }) => { + toast.dismiss(lastToastId); + setCode(code); + setRuntimes(runtimes); + setResults(results); + }, + ); + + return () => { + removeUnknownRuntimeListener(); + removeExecutionStartedListener(); + removeExecutionFailedListener(); + removeExecutionFinishedListener(); + }; + }, []); + + return ( + + ); +} diff --git a/apps/browser-extension/src/content-script/execution/result.tsx b/apps/browser-extension/src/content-script/execution/result.tsx new file mode 100644 index 00000000..82b8d182 --- /dev/null +++ b/apps/browser-extension/src/content-script/execution/result.tsx @@ -0,0 +1,71 @@ +import { Button } from '@evaluate/components/button'; +import { Label } from '@evaluate/components/label'; +import { Textarea } from '@evaluate/components/textarea'; +import type { ExecuteResult, PartialRuntime } from '@evaluate/shapes'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { twMerge as cn } from 'tailwind-merge'; +import { makeEditCodeUrl, makePickRuntimeUrl } from '~/helpers/make-url'; + +export function ResultDialog({ + code, + runtime, + result, +}: { + code: string; + runtime: PartialRuntime; + result: ExecuteResult; +}) { + const display = useMemo(() => { + if (!result) return { code: undefined, output: undefined }; + if (result.compile?.code) return result.compile; + return result.run; + }, [result]); + + return ( +
+
+ +