diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 11b8c95..1238f39 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,4 +1,5 @@ import { defineBackground } from 'wxt/utils/define-background'; +import nacl from 'tweetnacl'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -10,6 +11,8 @@ interface AnalyzePayload { interface LexAIConfig { provider?: string; apiKey?: string; + apiKeyEnc?: string; + encKey?: string; model?: string; } @@ -39,13 +42,26 @@ function getSystemPrompt(action: string): string { 'Make it richer and more informative while staying on topic. ' + 'Return ONLY the expanded text.', tone: - 'You are a writing coach. Analyze the tone of the provided text (e.g. formal, casual, aggressive, passive) ' + - 'and rewrite it to be professional and clear. ' + - 'Return ONLY the improved text.', + 'You are a writing coach. Analyze the tone of the provided text. ' + + 'Describe the tone characteristics (e.g. formal, casual, aggressive, passive, confident, etc.) ' + + 'and note any issues like passive voice, wordiness, or emotional bias. ' + + 'Return a brief, clear analysis — no rewriting, no extra commentary.', }; return prompts[action] ?? prompts.grammar; } +// ─── Encryption helpers ─────────────────────────────────────────────────────── + +async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise { + const key = Uint8Array.from(atob(encKeyB64), c => c.charCodeAt(0)); + const combined = Uint8Array.from(atob(apiKeyEncB64), c => c.charCodeAt(0)); + const nonce = combined.slice(0, 24); + const cipher = combined.slice(24); + const decrypted = nacl.secretbox.open(cipher, nonce, key); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} + // ─── Provider implementations ───────────────────────────────────────────────── async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise { @@ -198,23 +214,34 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro // ─── Main handler ───────────────────────────────────────────────────────────── async function handleAnalyzeText(payload: AnalyzePayload): Promise { - const config = await chrome.storage.local.get(['provider', 'apiKey', 'model']) as LexAIConfig; + const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']); + const config = stored as LexAIConfig; - if (!config.apiKey || config.apiKey.trim() === '') { + // Resolve API key — prefer encrypted path, fall back to plaintext for backward compat + let apiKey = config.apiKey; + if (config.apiKeyEnc && config.encKey) { + const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); + if (decrypted) { + apiKey = decrypted; + } + } + + if (!apiKey || apiKey.trim() === '') { return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' }; } + const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() }; const provider = config.provider || 'openai'; switch (provider) { case 'openai': - return callOpenAI(payload, config); + return callOpenAI(payload, resolvedConfig); case 'anthropic': - return callAnthropic(payload, config); + return callAnthropic(payload, resolvedConfig); case 'groq': - return callGroq(payload, config); + return callGroq(payload, resolvedConfig); case 'openrouter': - return callOpenRouter(payload, config); + return callOpenRouter(payload, resolvedConfig); default: return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; } @@ -225,6 +252,46 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise { console.log('LexAI background service worker started'); + // ─── Context menus ─────────────────────────────────────────────────────── + chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'lexai-grammar', + title: '⚡ LexAI: Fix Grammar', + contexts: ['selection'], + }); + chrome.contextMenus.create({ + id: 'lexai-rephrase', + title: '⚡ LexAI: Rephrase', + contexts: ['selection'], + }); + chrome.contextMenus.create({ + id: 'lexai-shorten', + title: '⚡ LexAI: Shorten', + contexts: ['selection'], + }); + chrome.contextMenus.create({ + id: 'lexai-expand', + title: '⚡ LexAI: Expand', + contexts: ['selection'], + }); + chrome.contextMenus.create({ + id: 'lexai-tone', + title: '⚡ LexAI: Analyze Tone', + contexts: ['selection'], + }); + }); + + chrome.contextMenus.onClicked.addListener((info, tab) => { + if (!info.selectionText || !tab?.id) return; + const action = info.menuItemId.toString().replace('lexai-', ''); + chrome.tabs.sendMessage(tab.id, { + type: 'lexai-context-menu', + action, + text: info.selectionText, + }); + }); + + // ─── Message handler ───────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'ANALYZE_TEXT') { handleAnalyzeText(message.payload as AnalyzePayload) diff --git a/entrypoints/content.ts b/entrypoints/content.ts index ba2fc1e..a4db62f 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -186,6 +186,7 @@ export default defineContentScript({ { label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' }, { label: '↓ Shorten', action: 'shorten', color: '#fab387' }, { label: '↑ Expand', action: 'expand', color: '#cba6f7' }, + { label: '🎭 Tone', action: 'tone', color: '#2dd4bf' }, ]; actions.forEach(({ label, action, color }) => { @@ -340,7 +341,13 @@ export default defineContentScript({ if (response?.error) { showModal(`❌ ${response.error}`, null, snapStart, snapEnd, snapElement, snapRange); } else { - showModal(response?.result ?? '(no result)', textToProcess, snapStart, snapEnd, snapElement, snapRange); + // Tone analysis is display-only — no Replace button (pass null for originalText) + const isToneAction = action === 'tone'; + showModal( + response?.result ?? '(no result)', + isToneAction ? null : textToProcess, + snapStart, snapEnd, snapElement, snapRange, + ); } } catch (err) { hideToolbar(); @@ -653,5 +660,18 @@ export default defineContentScript({ } } }); + + // ─── Context menu trigger from background ───────────────────────────────── + chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'lexai-context-menu') { + selectedText = message.text as string; + // For context menu, we have no DOM selection positions — zero them out + storedStart = -1; + storedEnd = -1; + storedElement = null; + storedRange = null; + runAction(message.action as string); + } + }); }, }); diff --git a/entrypoints/options/Options.tsx b/entrypoints/options/Options.tsx index 9ff2758..f511992 100644 --- a/entrypoints/options/Options.tsx +++ b/entrypoints/options/Options.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import nacl from 'tweetnacl'; // ─── Provider config ────────────────────────────────────────────────────────── @@ -34,6 +35,23 @@ const PROVIDERS = [ }, ]; +// ─── Encryption helpers ─────────────────────────────────────────────────────── + +async function getOrCreateEncKey(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(['encKey'], (result) => { + if (result.encKey) { + resolve(Uint8Array.from(atob(result.encKey as string), c => c.charCodeAt(0))); + } else { + const key = nacl.randomBytes(32); + const keyB64 = btoa(String.fromCharCode(...key)); + chrome.storage.local.set({ encKey: keyB64 }); + resolve(key); + } + }); + }); +} + // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = { @@ -183,13 +201,22 @@ function OptionsPage() { const [model, setModel] = useState('gpt-4o-mini'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [showKey, setShowKey] = useState(false); + const [isEncrypted, setIsEncrypted] = useState(false); const apiKeyRef = useRef(null); useEffect(() => { - safeStorageGet(["provider", "apiKey", "model"], (result) => { + safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => { if (result.provider) setProvider(result.provider); - if (result.apiKey) setApiKey(result.apiKey); if (result.model) setModel(result.model); + + // Show placeholder if encrypted key exists + if (result.apiKeyEnc && result.encKey) { + setIsEncrypted(true); + setApiKey(''); // don't show encrypted blob — show empty for re-entry or leave as is + } else if (result.apiKey) { + setApiKey(result.apiKey); + setIsEncrypted(false); + } }); }, []); @@ -201,21 +228,57 @@ function OptionsPage() { if (p) setModel(p.models[0]); }; - const handleSave = () => { - if (!apiKey.trim()) { + const handleSave = async () => { + // If key is blank and we already have an encrypted key, don't overwrite + if (!apiKey.trim() && !isEncrypted) { apiKeyRef.current?.focus(); return; } + if (!apiKey.trim() && isEncrypted) { + // Only saving provider/model changes — preserve existing encrypted key + setSaveStatus('saving'); + safeStorageSet({ provider, model }, () => { + if (chrome.runtime.lastError) { + setSaveStatus('error'); + setTimeout(() => setSaveStatus('idle'), 3000); + } else { + setSaveStatus('saved'); + setTimeout(() => setSaveStatus('idle'), 2500); + } + }); + return; + } + setSaveStatus('saving'); - safeStorageSet({ provider, apiKey: apiKey.trim(), model }, () => { - if (chrome.runtime.lastError) { - setSaveStatus('error'); - setTimeout(() => setSaveStatus('idle'), 3000); - } else { - setSaveStatus('saved'); - setTimeout(() => setSaveStatus('idle'), 2500); - } - }); + + try { + const key = await getOrCreateEncKey(); + const nonce = nacl.randomBytes(24); + const encoded = new TextEncoder().encode(apiKey.trim()); + const encrypted = nacl.secretbox(encoded, nonce, key); + const combined = new Uint8Array(nonce.length + encrypted.length); + combined.set(nonce); + combined.set(encrypted, nonce.length); + const apiKeyEncB64 = btoa(String.fromCharCode(...combined)); + + safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => { + if (chrome.runtime.lastError) { + setSaveStatus('error'); + setTimeout(() => setSaveStatus('idle'), 3000); + } else { + // Remove plaintext key if it existed + chrome.storage.local.remove('apiKey'); + setIsEncrypted(true); + setApiKey(''); + setSaveStatus('saved'); + setTimeout(() => setSaveStatus('idle'), 2500); + } + }); + } catch (err) { + console.error('LexAI: Encryption failed', err); + setSaveStatus('error'); + setTimeout(() => setSaveStatus('idle'), 3000); + } }; const saveBtnStyle: React.CSSProperties = { @@ -237,7 +300,7 @@ function OptionsPage() {

LexAI Settings

- Configure your LLM provider and API key. Your key is stored locally and never shared. + Configure your LLM provider and API key. Your key is encrypted and stored locally.

@@ -272,14 +335,27 @@ function OptionsPage() { {/* API Key */}
- +
setApiKey(e.target.value)} - placeholder={currentProvider.placeholder} + onChange={(e) => { + setApiKey(e.target.value); + if (isEncrypted && e.target.value) setIsEncrypted(false); + }} + placeholder={isEncrypted ? '••••••• (encrypted — enter new key to change)' : currentProvider.placeholder} style={{ ...styles.input, paddingRight: '42px' }} onKeyDown={(e) => e.key === 'Enter' && handleSave()} /> @@ -303,7 +379,7 @@ function OptionsPage() {