/** * Message router (background). * * - runtime.Port ('translation-stream'): translate / cancel requests from * the content script, streaming started/chunk/completed/error back. * - runtime.onMessage: one-shot requests for popup/options: * - listModels * - getPopupState (popup translation result mode) * - regenerateLast * * The actual translation pipeline (validation, prompt, Ollama request, * session/abort, shared state) lives in translation-service.js and is used * by BOTH the content-script port flow and the context-menu flow, so there * is a single request-generation implementation (Design.md §9.2). */ import { RUNTIME_MESSAGES, ERROR_CODES } from '../shared/constants.js'; import { isValidTranslateRequest, isValidCancelRequest, isTrustedSender, createRequestId } from '../shared/messages.js'; import { getSettings } from './settings-service.js'; import { listModels } from './ollama-client.js'; import { runTranslation, getSession, getTranslationState, addPopupPort, removePopupPort } from './translation-service.js'; /** * Handle one content-script port connection. * @param {object} port runtime.Port */ export function handleConnection(port) { if (!port || port.name !== 'translation-stream') return; port.onMessage.addListener((message) => { if (!isTrustedSender(port.sender)) return; if (isValidTranslateRequest(message)) { void runTranslation({ text: message.text, requestId: message.requestId, source: 'content', sink: port }); return; } if (isValidCancelRequest(message)) { if (getSession().isActive(message.requestId)) { getSession().clear(message.requestId); } return; } // Invalid message: discard silently (do not echo details to the page). }); port.onDisconnect.addListener(() => { // If the tab goes away, clear the session entry owned by this port. // The popup-facing translation state keeps whatever was accumulated so // reopening the popup still shows the latest text. if (getSession().hasActive() && getSession().active.port === port) { const requestId = getSession().active.requestId; getSession().clear(requestId); } }); } /** * Handle one popup-state port connection. The popup subscribes to live * translation state updates (Design.md §7.4, §9.2). * @param {object} port runtime.Port */ export function handlePopupStateConnection(port) { if (!port || port.name !== 'popup-state') return; addPopupPort(port); port.onDisconnect.addListener(() => { removePopupPort(port); }); } /** * Register one-shot message handling (popup / options). * @param {object} browserApi injectable for tests */ export function registerRuntimeMessageHandler(browserApi) { const b = browserApi || globalThis.browser; b.runtime.onMessage.addListener((message, sender) => { if (!isTrustedSender(sender)) return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST }); if (message && message.type === RUNTIME_MESSAGES.LIST_MODELS) { return (async () => { try { const settings = await getSettings(browserApi); const models = await listModels(settings.ollamaHost); return { ok: true, models }; } catch (error) { return { ok: false, error: (error && error.code) || ERROR_CODES.OLLAMA_UNAVAILABLE, message: (error && error.message) || '' }; } })(); } if (message && message.type === RUNTIME_MESSAGES.GET_POPUP_STATE) { return Promise.resolve({ ok: true, state: getTranslationState() }); } if (message && message.type === RUNTIME_MESSAGES.REGENERATE_LAST) { return (async () => { try { const state = getTranslationState(); if (!state.sourceText) { return { ok: false, error: ERROR_CODES.TEXT_EMPTY }; } const requestId = createRequestId(); void runTranslation({ text: state.sourceText, requestId, source: state.source || 'context-menu', sink: null }); return { ok: true, requestId }; } catch (error) { return { ok: false, error: (error && error.code) || ERROR_CODES.UNKNOWN_ERROR }; } })(); } return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST }); }); } /** * Test-only hook: inject a browser API so the router can be exercised * outside a real extension context. Not used at runtime. * @param {object} browserApi * @visibleForTesting */ export function __setBrowserApiForTests(browserApi) { testBrowserApi = browserApi; browserApi.runtime.onConnect.addListener(handleConnection); registerRuntimeMessageHandler(browserApi); } /** @type {object|null} test-injected browser api */ let testBrowserApi = null;