From 55e581bab9b600e7c070ec596d3109fc897d2d86 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 1 Sep 2026 20:10:20 +0900 Subject: feat: Firefox selection translation extension with local Ollama (initial version) Implement the complete initial version of a Firefox WebExtension (Manifest V3) that translates selected text on web pages using a local Ollama LLM and shows the result in a small popover near the selection. Specification alignment (Phase 0) - Unified Specifications.md / Design.md / Security.md on the same approach: Ollama REST API + Fetch API + NDJSON streaming (no ollama-js SDK dependency) - Fixed host_permissions to http://localhost/* and http://127.0.0.1/* because Firefox match patterns do not support port numbers (bug 1362809/1468162); the actual connection target is restricted to localhost/127.0.0.1 + any port by application-side validation (defense in depth, OLL-09) - Clipboard: use navigator.clipboard.writeText() only inside the copy button click (user activation); no deprecated execCommand fallback Implementation (zero dependencies, vanilla JS, no npm/Node required) - manifest.json: MV3, background.scripts + type:module (event page, no service_worker), action popup, options_ui, content_scripts for http/https, permissions: storage + localhost host permissions only - src/shared: constants (ports, limits, presets, error codes), message validation, settings schema (defaults / merge / sanitize / buildOptions / toApiThinking), system prompt builder (fixed prompt first + user prompt) - src/background: message-router (runtime.Port streaming + runtime.onMessage for model list), translation-session (single active request, abort previous on new request, stale chunk suppression), ollama-client (fetch against /api/tags and /api/chat with buffering NDJSON line parser tolerant to chunk boundaries inside JSON objects, AbortController, host re-validation before every request, HTTP status to error-code mapping), settings-service (storage.local load/save with validation and fallback to defaults) - src/content/content.js: single classic script (Firefox does not support ES modules in content scripts) with selection detection (selectionchange + mouseup + keyup, debounced), Shadow DOM action button, Shadow DOM popover with HIDDEN/LOADING/STREAMING/COMPLETED/ERROR state machine, port client with requestId-based stale chunk filtering, enabled-state sync via storage.onChanged; all dynamic text rendered via textContent only - src/popup + src/options: full settings UI (enable toggle, Ollama host, model list from Ollama with saved-model warning, target language, thinking mode, temperature slider 0.0-2.0 step 0.1, context length presets 4k/8k/16k/32k/128k/256k, seed random/fixed with integer value, user system prompt, advanced numeric options with steppers, stop sequences textarea) - icons generated by tools/make-icons.ps1 (System.Drawing, no external tools) - build.ps1 packages manifest.json + icons/ + src/ into dist zip, excluding tests/, doc/, ollama-js/ and tools/ Tests (71 passed, 0 failed) - In-browser test runner (tests/test.html) with custom describe/it/assert framework; no npm, no Node.js - Unit: constants sync between content mirror and shared module, Ollama host validation (localhost/127.0.0.1 + any port only; rejects external hosts, LAN IPs, https, credentials, paths), settings schema defaults and fallback, temperature 0.1-step validation, context length presets, seed modes, advanced option types, buildOptions omission of unset values and absence of tfs_z/mirostat fields, system prompt composition order, message validation with selection length boundary values - Integration: NDJSON streaming across awkward byte boundaries, thinking chunk forwarding, malformed line tolerance, error payload mapping, think/ options inclusion rules, model list, TranslationSession abort semantics, settings round-trip with storage.onChanged cache refresh, full port flow (started/chunk/completed), newer request aborts older with stale chunk discard, malformed message rejection, OLLAMA_UNAVAILABLE and INVALID_SETTINGS error paths, untrusted sender rejection Security audit (Design.md section 24 checklist: all pass) - No innerHTML/outerHTML/document.write/eval/new Function/localStorage - No remote JS/CSS/fonts/CDN resources; no inline scripts in extension pages - Ollama access limited to background; content scripts never fetch Ollama - Background validates message type/length/requestId; single concurrent translation enforced - Clipboard API only (no execCommand fallback) - Minimal permissions: storage + localhost host permissions Docs - README.md with build/load/usage instructions, API list, permission rationale - doc/Testing-Results.md with automated test results, security audit results, and a 14-item manual Firefox verification checklist (to be executed on a real Firefox + Ollama environment) --- src/content/content.css | 10 + src/content/content.js | 676 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 686 insertions(+) create mode 100644 src/content/content.css create mode 100644 src/content/content.js (limited to 'src/content') diff --git a/src/content/content.css b/src/content/content.css new file mode 100644 index 0000000..169534d --- /dev/null +++ b/src/content/content.css @@ -0,0 +1,10 @@ +/* + * Content script styles. Injected by manifest.json (content_scripts.css). + * Styles for the Shadow DOM hosts live inside each shadow root; these rules + * only neutralize layout impact of the host elements. + */ + +.ollama-translations-host { + all: initial; + display: contents; +} \ No newline at end of file diff --git a/src/content/content.js b/src/content/content.js new file mode 100644 index 0000000..e1e6823 --- /dev/null +++ b/src/content/content.js @@ -0,0 +1,676 @@ +/* + * Content script (classic script, single file). + * + * Sections: + * 1. constants (mirrored from src/shared/constants.js - keep in sync, + * enforced by unit tests) + * 2. selection controller + * 3. action button (Shadow DOM) + * 4. translation popover (Shadow DOM, state machine) + * 5. port client (runtime.Port to background) + * 6. entry point + * + * Security rules (Security.md): + * - never call Ollama directly; + * - all dynamic text is rendered with textContent; + * - no eval / new Function / remote code. + */ +(() => { + 'use strict'; + + // --------------------------------------------------------------------- + // 1. Constants (mirrored from src/shared/constants.js) + // --------------------------------------------------------------------- + + const PORT_NAME = 'translation-stream'; + const MAX_SELECTION_LENGTH = 20000; + const ERROR_CODES = { + INVALID_REQUEST: 'INVALID_REQUEST', + TEXT_EMPTY: 'TEXT_EMPTY', + TEXT_TOO_LONG: 'TEXT_TOO_LONG', + OLLAMA_UNAVAILABLE: 'OLLAMA_UNAVAILABLE', + MODEL_NOT_FOUND: 'MODEL_NOT_FOUND', + INVALID_SETTINGS: 'INVALID_SETTINGS', + REQUEST_ABORTED: 'REQUEST_ABORTED', + STREAM_ERROR: 'STREAM_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR' + }; + const CONTENT_TO_BG = { TRANSLATE: 'translate', CANCEL: 'cancel' }; + const BG_TO_CONTENT = { STARTED: 'started', CHUNK: 'chunk', COMPLETED: 'completed', ERROR: 'error' }; + + const POPOVER_STATES = { + HIDDEN: 'HIDDEN', + LOADING: 'LOADING', + STREAMING: 'STREAMING', + COMPLETED: 'COMPLETED', + ERROR: 'ERROR' + }; + + // --------------------------------------------------------------------- + // State + // --------------------------------------------------------------------- + + /** @type {boolean} */ + let enabled = true; + /** @type {DOMRect|null} rect of the latest stable selection */ + let currentSelectionRect = null; + /** @type {string|null} id of the request the popover currently shows */ + let activeRequestId = null; + /** @type {string|null} source text of the last request (for regenerate) */ + let lastSourceText = null; + + // --------------------------------------------------------------------- + // 2. Selection controller + // --------------------------------------------------------------------- + + /** + * Read the current selection. Returns null when there is nothing to show. + * Top-level document only (SEL-05). + * @returns {{text: string, rect: DOMRect}|null} + */ + function readSelection() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + const text = selection.toString().trim(); + if (!text) return null; + if (text.length > MAX_SELECTION_LENGTH) return null; + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (!rect || (rect.width === 0 && rect.height === 0)) return null; + return { text, rect }; + } + + /** + * Debounce helper. + * @param {Function} fn + * @param {number} wait ms + */ + function debounce(fn, wait) { + let timer = 0; + return (...args) => { + window.clearTimeout(timer); + timer = window.setTimeout(() => fn(...args), wait); + }; + } + + const scheduleUpdate = debounce(() => { + const selection = readSelection(); + if (selection) { + currentSelectionRect = selection.rect; + actionButton.show(selection.rect); + if (popover.isVisible()) { + // Follow the newest selection rect while visible (ACT-05). + popover.repositionTo(selection.rect); + } + } else { + currentSelectionRect = null; + actionButton.hide(); + // Selection cleared: hide the popover too (SEL-03), cancelling any + // in-flight request. + if (popover.isVisible()) { + popover.close(); + } + } + }, 150); + + function onSelectionEvent() { + if (!enabled) return; + scheduleUpdate(); + } + + // --------------------------------------------------------------------- + // 3. Action button (Shadow DOM) + // --------------------------------------------------------------------- + + const actionButton = (() => { + let host = null; + let shadow = null; + let wrap = null; + let button = null; + + function ensureDom() { + if (host) return; + host = document.createElement('div'); + host.id = 'ollama-translations-action-host'; + host.className = 'ollama-translations-host'; + document.documentElement.appendChild(host); + shadow = host.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; } + .wrap { + position: fixed; + z-index: 2147483647; + display: none; + } + .btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid rgba(0,0,0,.25); + background: #ffffff; + box-shadow: 0 1px 4px rgba(0,0,0,.35); + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + .btn:hover { background: #eef3ff; } + .btn svg { width: 18px; height: 18px; display: block; } + `; + shadow.append(style); + + button = document.createElement('button'); + button.type = 'button'; + button.title = '選択範囲を翻訳'; + button.setAttribute('aria-label', '選択範囲を翻訳'); + + // Inline SVG (project-owned, no external resources). + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute( + 'd', + 'M12.87 15.07 10.33 12.56 10.36 12.53C12.1 10.59 13.27 8.36 13.93 6.26H16V4.26H9V2.26H7V4.26H0V6.24H12.17 ' + + 'C11.5 8 10.44 9.65 9 11.16 8.07 10.18 7.3 9.11 6.69 8H4.69C5.42 9.63 6.42 11.16 7.67 12.53L2.63 17.54 4.05 ' + + '18.96 9.5 13.51 12.87 15.07M17.5 10H15.5L11 22H13L14.12 19H18.88L20 22H22L17.5 10M14.88 17 16.5 12.66 18.12 ' + + '17H14.88Z' + ); + path.setAttribute('fill', '#1a73e8'); + svg.append(path); + button.append(svg); + + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + onTranslateClicked(); + }); + + wrap = document.createElement('div'); + wrap.className = 'wrap'; + wrap.append(button); + shadow.append(wrap); + } + + /** + * Show the button near the selection rect. + * @param {DOMRect} rect + */ + function show(rect) { + if (!enabled) return; + ensureDom(); + const margin = 6; + const size = 28; + let left = rect.right + margin; + let top = rect.top; + // Clamp into the viewport (ACT-03 / Design 5.4). + if (left + size > window.innerWidth - 4) left = rect.left - size - margin; + if (left + size > window.innerWidth - 4) left = window.innerWidth - size - 4; + if (left < 4) left = 4; + if (top + size > window.innerHeight - 4) top = window.innerHeight - size - 4; + if (top < 4) top = 4; + wrap.style.left = `${Math.round(left)}px`; + wrap.style.top = `${Math.round(top)}px`; + wrap.style.display = 'block'; + } + + function hide() { + if (wrap) wrap.style.display = 'none'; + } + + return { show, hide }; + })(); + + // --------------------------------------------------------------------- + // 4. Translation popover (Shadow DOM) + // --------------------------------------------------------------------- + + const popover = (() => { + let host = null; + let shadow = null; + let panel = null; + let body = null; + let meta = null; + let status = null; + let copyButton = null; + let regenerateButton = null; + let closeButton = null; + let currentRect = null; + + let state = POPOVER_STATES.HIDDEN; + /** @type {string} accumulated translation text of the current request */ + let text = ''; + /** @type {string|null} text of the last requested translation */ + let localLastSourceText = null; + + function ensureDom() { + if (host) return; + host = document.createElement('div'); + host.id = 'ollama-translations-popover-host'; + host.className = 'ollama-translations-host'; + document.documentElement.append(host); + shadow = host.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; } + .panel { + position: fixed; + z-index: 2147483647; + width: 340px; + max-width: calc(100vw - 16px); + max-height: 320px; + display: none; + flex-direction: column; + background: #ffffff; + color: #1f1f1f; + border: 1px solid rgba(0,0,0,.28); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,.3); + font-family: system-ui, sans-serif; + font-size: 13px; + line-height: 1.5; + } + .header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid rgba(0,0,0,.12); + background: #f6f8fa; + border-radius: 8px 8px 0 0; + } + .title { font-weight: 600; flex: 1; } + .meta { color: #5f6368; font-size: 11px; } + .close { + border: none; background: transparent; cursor: pointer; + font-size: 14px; line-height: 1; padding: 2px 4px; color: #444; + } + .body { + padding: 8px 10px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + min-height: 24px; + user-select: text; + } + .footer { + display: flex; gap: 6px; padding: 6px 10px; + border-top: 1px solid rgba(0,0,0,.12); + } + .btn { + border: 1px solid rgba(0,0,0,.25); + background: #fff; border-radius: 4px; + padding: 3px 10px; cursor: pointer; font-size: 12px; + font-family: system-ui, sans-serif; + } + .btn:hover { background: #f0f4ff; } + .btn[disabled] { opacity: .5; cursor: default; } + .status { color: #5f6368; font-size: 11px; padding: 0 10px 6px; } + .state-ERROR .status { color: #c5221f; } + .state-COMPLETED .status { color: #188038; } + `; + shadow.append(style); + + panel = document.createElement('div'); + panel.className = 'panel'; + + const header = document.createElement('div'); + header.className = 'header'; + const title = document.createElement('span'); + title.className = 'title'; + title.textContent = '翻訳'; + meta = document.createElement('span'); + meta.className = 'meta'; + closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'close'; + closeButton.textContent = '✕'; + closeButton.title = '閉じる'; + closeButton.setAttribute('aria-label', '閉じる'); + header.append(title, meta, closeButton); + + body = document.createElement('div'); + body.className = 'body'; + + status = document.createElement('div'); + status.className = 'status'; + + const footer = document.createElement('div'); + footer.className = 'footer'; + copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'btn'; + copyButton.textContent = 'コピー'; + regenerateButton = document.createElement('button'); + regenerateButton.type = 'button'; + regenerateButton.className = 'btn'; + regenerateButton.textContent = '再生成'; + footer.append(copyButton, regenerateButton); + + panel.append(header, body, status, footer); + shadow.append(panel); + + closeButton.addEventListener('click', () => { + close(); + }); + copyButton.addEventListener('click', () => { + void copyToClipboard(); + }); + regenerateButton.addEventListener('click', () => { + if (localLastSourceText) { + requestTranslation(localLastSourceText); + } + }); + } + + function setState(next) { + state = next; + if (panel) { + panel.className = `panel state-${next}`; + } + updateControls(); + } + + function updateControls() { + const busy = state === POPOVER_STATES.LOADING || state === POPOVER_STATES.STREAMING; + copyButton.disabled = busy || text.length === 0; + regenerateButton.disabled = busy || !localLastSourceText; + if (state === POPOVER_STATES.LOADING) status.textContent = '翻訳中…'; + else if (state === POPOVER_STATES.STREAMING) status.textContent = '受信中…'; + else if (state === POPOVER_STATES.COMPLETED) status.textContent = '完了'; + else if (state === POPOVER_STATES.ERROR) status.textContent = 'エラー'; + else status.textContent = ''; + } + + /** + * Show the popover near the given rect. + * @param {DOMRect|{left:number,top:number,bottom:number,right:number}} rect + */ + function show(rect) { + ensureDom(); + currentRect = rect; + panel.style.display = 'flex'; + reposition(); + } + + function reposition() { + if (!currentRect || !panel) return; + const width = 340; + const max = window.innerWidth - 8; + let left = currentRect.left; + if (left + width > max) left = Math.max(4, max - width); + if (left < 4) left = 4; + let top = currentRect.bottom + 8; + // If it would overflow the bottom, place it above the selection. + const approxHeight = Math.min(320, Math.max(80, panel.getBoundingClientRect().height || 120)); + if (top + approxHeight > window.innerHeight - 4) { + top = Math.max(4, currentRect.top - approxHeight - 8); + } + panel.style.left = `${Math.round(left)}px`; + panel.style.top = `${Math.round(top)}px`; + } + + function isVisible() { + return Boolean(panel && panel.style.display !== 'none' && state !== POPOVER_STATES.HIDDEN); + } + + function repositionTo(rect) { + currentRect = rect; + reposition(); + } + + function hideInternal() { + if (panel) panel.style.display = 'none'; + setState(POPOVER_STATES.HIDDEN); + text = ''; + activeRequestId = null; + } + + function close() { + if (activeRequestId) { + portClient.cancel(activeRequestId); + } + hideInternal(); + } + + /** + * Begin a new translation request. + * @param {string} sourceText + * @param {DOMRect|{left:number,top:number,bottom:number,right:number}} rect + */ + function beginRequest(sourceText, rect) { + ensureDom(); + localLastSourceText = sourceText; + text = ''; + body.textContent = ''; + show(rect); + setState(POPOVER_STATES.LOADING); + } + + /** @param {string} modelName */ + function setModel(modelName) { + if (meta) meta.textContent = modelName ? `Ollama / ${modelName}` : 'Ollama'; + } + + /** @param {string} partial */ + function appendChunk(partial) { + if (!partial) return; + text += partial; + // Plain text rendering only (UI-04 / SEC-03). + body.textContent = text; + if (state !== POPOVER_STATES.STREAMING) setState(POPOVER_STATES.STREAMING); + } + + function complete() { + setState(POPOVER_STATES.COMPLETED); + } + + /** @param {string} message */ + function showError(message) { + setState(POPOVER_STATES.ERROR); + status.textContent = message || 'エラーが発生しました。'; + } + + async function copyToClipboard() { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + status.textContent = 'コピーしました'; + } catch { + // No execCommand fallback (deprecated): show failure only (Design §20). + status.textContent = 'コピーできませんでした'; + } + } + + return { + get state() { + return state; + }, + isVisible, + repositionTo, + close, + beginRequest, + setModel, + appendChunk, + complete, + showError + }; + })(); + + // --------------------------------------------------------------------- + // 5. Port client + // --------------------------------------------------------------------- + + const portClient = (() => { + /** @type {object|null} */ + let port = null; + + function connect() { + if (port) return port; + port = browser.runtime.connect({ name: PORT_NAME }); + port.onMessage.addListener((message) => { + if (!message || typeof message !== 'object' || typeof message.requestId !== 'string') return; + if (message.requestId !== activeRequestId) return; // stale chunk + switch (message.type) { + case BG_TO_CONTENT.STARTED: + popover.setModel(typeof message.model === 'string' ? message.model : ''); + break; + case BG_TO_CONTENT.CHUNK: + if (typeof message.text === 'string') popover.appendChunk(message.text); + break; + case BG_TO_CONTENT.COMPLETED: + popover.complete(); + break; + case BG_TO_CONTENT.ERROR: + if (message.code !== ERROR_CODES.REQUEST_ABORTED) { + popover.showError(typeof message.message === 'string' ? message.message : ''); + } + break; + default: + break; + } + }); + port.onDisconnect.addListener(() => { + port = null; + }); + return port; + } + + return { + /** + * Start a translation request for the given text, showing the popover. + * @param {string} text + */ + translate(text) { + const p = connect(); + const requestId = generateRequestId(); + activeRequestId = requestId; + lastSourceText = text; + popover.beginRequest(text, currentSelectionRect || { left: 16, top: 16, bottom: 40, right: 356 }); + p.postMessage({ type: CONTENT_TO_BG.TRANSLATE, requestId, text }); + }, + /** + * @param {string} requestId + */ + cancel(requestId) { + const p = port || connect(); + p.postMessage({ type: CONTENT_TO_BG.CANCEL, requestId }); + } + }; + })(); + + /** @returns {string} random request id */ + function generateRequestId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + // --------------------------------------------------------------------- + // Flow: icon click -> translate, regenerate + // --------------------------------------------------------------------- + + function onTranslateClicked() { + if (!enabled) return; + const selection = readSelection(); + if (!selection) return; + currentSelectionRect = selection.rect; + portClient.translate(selection.text); + } + + /** + * Request a translation for an arbitrary source text (regenerate). + * @param {string} sourceText + */ + function requestTranslation(sourceText) { + portClient.translate(sourceText); + } + + // --------------------------------------------------------------------- + // Enabled state (storage.local, EN-01/EN-02) + // --------------------------------------------------------------------- + + function applyEnabled(next) { + enabled = typeof next === 'boolean' ? next : true; + if (!enabled) { + actionButton.hide(); + if (popover.state !== POPOVER_STATES.HIDDEN) { + popover.close(); + } + } + } + + function readEnabled() { + try { + browser.storage.local + .get({ settings: null }) + .then((result) => { + const settings = result && result.settings; + applyEnabled(settings && typeof settings.enabled === 'boolean' ? settings.enabled : true); + }) + .catch(() => {}); + } catch { + // storage may be unavailable early; default stays enabled + } + } + + try { + browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local' || !changes.settings) return; + const next = changes.settings.newValue; + applyEnabled(next && typeof next.enabled === 'boolean' ? next.enabled : true); + }); + } catch { + // ignore + } + + // --------------------------------------------------------------------- + // 6. Entry point + // --------------------------------------------------------------------- + + window.addEventListener('selectionchange', onSelectionEvent, { passive: true }); + document.addEventListener('mouseup', onSelectionEvent, { passive: true }); + document.addEventListener('keyup', onSelectionEvent, { passive: true }); + + // Reposition or hide UI when the viewport changes (Design 5.4). + window.addEventListener( + 'scroll', + () => { + if (popover.state === POPOVER_STATES.HIDDEN) { + actionButton.hide(); + return; + } + const selection = readSelection(); + if (selection) { + currentSelectionRect = selection.rect; + actionButton.show(selection.rect); + popover.repositionTo(selection.rect); + } else { + actionButton.hide(); + } + }, + { passive: true, capture: true } + ); + + window.addEventListener( + 'resize', + () => { + if (popover.state !== POPOVER_STATES.HIDDEN) { + const selection = readSelection(); + if (selection) popover.repositionTo(selection.rect); + } + }, + { passive: true } + ); + + readEnabled(); +})(); \ No newline at end of file -- cgit v1.2.3