mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
1 commit
main
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268ea72271 |
14 changed files with 379 additions and 209 deletions
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(options): preserve focused provider options drafts
|
||||
5
.changeset/quiet-frames-speedometer.md
Normal file
5
.changeset/quiet-frames-speedometer.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
perf(extension): gate iframe content script injection
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { browser, storage } from "#imports"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { SITE_CONTROL_URL_WINDOW_KEY } from "@/utils/site-control"
|
||||
|
||||
|
|
@ -6,39 +7,12 @@ const webNavigationOnBeforeNavigateAddListenerMock = vi.fn()
|
|||
const webNavigationOnCompletedAddListenerMock = vi.fn()
|
||||
const getAllFramesMock = vi.fn()
|
||||
const executeScriptMock = vi.fn()
|
||||
const storageGetItemMock = vi.fn()
|
||||
|
||||
const getLocalConfigMock = vi.fn()
|
||||
const loggerErrorMock = vi.fn()
|
||||
const loggerWarnMock = vi.fn()
|
||||
|
||||
const browserMock = {
|
||||
tabs: {
|
||||
onRemoved: {
|
||||
addListener: tabsOnRemovedAddListenerMock,
|
||||
},
|
||||
},
|
||||
webNavigation: {
|
||||
onBeforeNavigate: {
|
||||
addListener: webNavigationOnBeforeNavigateAddListenerMock,
|
||||
},
|
||||
onCompleted: {
|
||||
addListener: webNavigationOnCompletedAddListenerMock,
|
||||
},
|
||||
getAllFrames: getAllFramesMock,
|
||||
},
|
||||
scripting: {
|
||||
executeScript: executeScriptMock,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
browser: browserMock,
|
||||
}))
|
||||
|
||||
vi.mock("wxt/browser", () => ({
|
||||
browser: browserMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/config/storage", () => ({
|
||||
getLocalConfig: getLocalConfigMock,
|
||||
}))
|
||||
|
|
@ -109,14 +83,32 @@ describe("setupIframeInjection", () => {
|
|||
vi.clearAllMocks()
|
||||
currentTabId += 1
|
||||
|
||||
browser.tabs.onRemoved.addListener = tabsOnRemovedAddListenerMock
|
||||
browser.webNavigation.onBeforeNavigate.addListener = webNavigationOnBeforeNavigateAddListenerMock
|
||||
browser.webNavigation.onCompleted.addListener = webNavigationOnCompletedAddListenerMock
|
||||
browser.webNavigation.getAllFrames = getAllFramesMock
|
||||
browser.scripting.executeScript = executeScriptMock
|
||||
storage.getItem = storageGetItemMock
|
||||
|
||||
getLocalConfigMock.mockResolvedValue(null)
|
||||
getAllFramesMock.mockResolvedValue([
|
||||
createFrame(0, "https://example.com/app", -1),
|
||||
createFrame(2, "https://example.com/frame"),
|
||||
])
|
||||
storageGetItemMock.mockResolvedValue({ enabled: true })
|
||||
executeScriptMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it("skips iframe injection when page translation is not enabled", async () => {
|
||||
const { onCompleted } = await setupSubject()
|
||||
storageGetItemMock.mockResolvedValue({ enabled: false })
|
||||
|
||||
await onCompleted(createDetails())
|
||||
|
||||
expect(getAllFramesMock).not.toHaveBeenCalled()
|
||||
expect(executeScriptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("injects each document once and targets documentIds when available", async () => {
|
||||
const { onCompleted } = await setupSubject()
|
||||
const details = createDetails()
|
||||
|
|
@ -124,7 +116,7 @@ describe("setupIframeInjection", () => {
|
|||
await onCompleted(details)
|
||||
await onCompleted(details)
|
||||
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(3)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(2)
|
||||
expect(executeScriptMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
target: { tabId: currentTabId, documentIds: ["doc-1"] },
|
||||
func: expect.any(Function),
|
||||
|
|
@ -141,7 +133,7 @@ describe("setupIframeInjection", () => {
|
|||
|
||||
await onCompleted(createDetails({ documentId: undefined }))
|
||||
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(3)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(2)
|
||||
for (const [call] of executeScriptMock.mock.calls) {
|
||||
expect(call.target).toEqual({ tabId: currentTabId, frameIds: [2] })
|
||||
}
|
||||
|
|
@ -153,12 +145,12 @@ describe("setupIframeInjection", () => {
|
|||
|
||||
await onCompleted(details)
|
||||
await onCompleted(details)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(3)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
onBeforeNavigate({ tabId: currentTabId, frameId: 2 })
|
||||
await onCompleted(details)
|
||||
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(6)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it("prunes injected records for frames that are no longer live", async () => {
|
||||
|
|
@ -194,10 +186,10 @@ describe("setupIframeInjection", () => {
|
|||
url: "https://example.com/old-frame",
|
||||
}))
|
||||
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(9)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(6)
|
||||
expect(executeScriptMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
target: { tabId: currentTabId, documentIds: ["doc-stale"] },
|
||||
files: ["/content-scripts/selection.js"],
|
||||
files: ["/content-scripts/host.js"],
|
||||
}))
|
||||
})
|
||||
|
||||
|
|
@ -207,14 +199,14 @@ describe("setupIframeInjection", () => {
|
|||
|
||||
await onCompleted(details)
|
||||
await onCompleted(details)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(3)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
onBeforeNavigate({ tabId: currentTabId, frameId: 0 })
|
||||
await onCompleted(details)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(6)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(4)
|
||||
|
||||
onRemoved(currentTabId)
|
||||
await onCompleted(details)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(9)
|
||||
expect(executeScriptMock).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
129
src/entrypoints/background/__tests__/translation-signal.test.ts
Normal file
129
src/entrypoints/background/__tests__/translation-signal.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { browser, storage } from "#imports"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { getTranslationStateKey } from "@/utils/constants/storage-keys"
|
||||
|
||||
const sendMessageMock = vi.fn()
|
||||
const onMessageMock = vi.fn()
|
||||
const storageGetItemMock = vi.fn()
|
||||
const storageSetItemMock = vi.fn()
|
||||
const storageRemoveItemMock = vi.fn()
|
||||
const tabsOnRemovedAddListenerMock = vi.fn()
|
||||
const webNavigationOnCommittedAddListenerMock = vi.fn()
|
||||
const injectHostContentIntoTabIframesMock = vi.fn()
|
||||
const loggerErrorMock = vi.fn()
|
||||
const loggerWarnMock = vi.fn()
|
||||
const shouldEnableAutoTranslationMock = vi.fn()
|
||||
|
||||
const messageHandlers = new Map<string, (msg: any) => any>()
|
||||
|
||||
vi.mock("@/utils/message", () => ({
|
||||
onMessage: onMessageMock,
|
||||
sendMessage: sendMessageMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/host/translate/auto-translation", () => ({
|
||||
shouldEnableAutoTranslation: shouldEnableAutoTranslationMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/logger", () => ({
|
||||
logger: {
|
||||
error: loggerErrorMock,
|
||||
warn: loggerWarnMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../iframe-injection", () => ({
|
||||
injectHostContentIntoTabIframes: injectHostContentIntoTabIframesMock,
|
||||
}))
|
||||
|
||||
function getHandler(name: string) {
|
||||
const handler = messageHandlers.get(name)
|
||||
if (!handler) {
|
||||
throw new Error(`Expected message handler to be registered: ${name}`)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
async function setupSubject() {
|
||||
const { translationMessage } = await import("../translation-signal")
|
||||
translationMessage()
|
||||
}
|
||||
|
||||
describe("translationMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
messageHandlers.clear()
|
||||
|
||||
browser.tabs.onRemoved.addListener = tabsOnRemovedAddListenerMock
|
||||
browser.webNavigation.onCommitted.addListener = webNavigationOnCommittedAddListenerMock
|
||||
storage.getItem = storageGetItemMock
|
||||
storage.setItem = storageSetItemMock
|
||||
storage.removeItem = storageRemoveItemMock
|
||||
|
||||
onMessageMock.mockImplementation((name: string, handler: (msg: any) => any) => {
|
||||
messageHandlers.set(name, handler)
|
||||
return vi.fn()
|
||||
})
|
||||
sendMessageMock.mockResolvedValue(undefined)
|
||||
storageGetItemMock.mockResolvedValue(undefined)
|
||||
storageSetItemMock.mockResolvedValue(undefined)
|
||||
storageRemoveItemMock.mockResolvedValue(undefined)
|
||||
shouldEnableAutoTranslationMock.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it("persists manager-enabled state and injects current iframes from the top frame", async () => {
|
||||
await setupSubject()
|
||||
|
||||
await getHandler("setAndNotifyPageTranslationStateChangedByManager")({
|
||||
data: { enabled: true },
|
||||
sender: { tab: { id: 42 }, frameId: 0 },
|
||||
})
|
||||
|
||||
expect(storageSetItemMock).toHaveBeenCalledWith(getTranslationStateKey(42), { enabled: true })
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("notifyTranslationStateChanged", { enabled: true }, 42)
|
||||
expect(injectHostContentIntoTabIframesMock).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it("does not reinject every iframe when an iframe manager echoes enabled state", async () => {
|
||||
await setupSubject()
|
||||
|
||||
await getHandler("setAndNotifyPageTranslationStateChangedByManager")({
|
||||
data: { enabled: true },
|
||||
sender: { tab: { id: 42 }, frameId: 7 },
|
||||
})
|
||||
|
||||
expect(storageSetItemMock).toHaveBeenCalledWith(getTranslationStateKey(42), { enabled: true })
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("notifyTranslationStateChanged", { enabled: true }, 42)
|
||||
expect(injectHostContentIntoTabIframesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("clears state immediately when a tab-level request disables page translation", async () => {
|
||||
await setupSubject()
|
||||
|
||||
await getHandler("tryToSetEnablePageTranslationByTabId")({
|
||||
data: { tabId: 42, enabled: false },
|
||||
})
|
||||
|
||||
expect(storageSetItemMock).toHaveBeenCalledWith(getTranslationStateKey(42), { enabled: false })
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("notifyTranslationStateChanged", { enabled: false }, 42)
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("askManagerToTogglePageTranslation", {
|
||||
enabled: false,
|
||||
analyticsContext: undefined,
|
||||
}, 42)
|
||||
})
|
||||
|
||||
it("waits for the top-frame manager to validate before enabling iframe injection", async () => {
|
||||
await setupSubject()
|
||||
|
||||
await getHandler("tryToSetEnablePageTranslationByTabId")({
|
||||
data: { tabId: 42, enabled: true },
|
||||
})
|
||||
|
||||
expect(storageSetItemMock).not.toHaveBeenCalled()
|
||||
expect(injectHostContentIntoTabIframesMock).not.toHaveBeenCalled()
|
||||
expect(sendMessageMock).toHaveBeenCalledWith("askManagerToTogglePageTranslation", {
|
||||
enabled: true,
|
||||
analyticsContext: undefined,
|
||||
}, 42)
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ import { CONFIG_STORAGE_KEY } from "@/utils/constants/config"
|
|||
import { getTranslationStateKey, TRANSLATION_STATE_KEY_PREFIX } from "@/utils/constants/storage-keys"
|
||||
import { sendMessage } from "@/utils/message"
|
||||
import { ensureInitializedConfig } from "./config"
|
||||
import { getPageTranslationEnabled, setPageTranslationEnabled } from "./page-translation-state"
|
||||
|
||||
export const MENU_ID_TRANSLATE = "read-frog-translate"
|
||||
export const MENU_ID_SELECTION_TRANSLATE = "read-frog-selection-translate"
|
||||
|
|
@ -194,14 +195,13 @@ async function handleContextMenuClick(
|
|||
* Handle translate menu click - toggle page translation
|
||||
*/
|
||||
async function handleTranslateClick(tabId: number) {
|
||||
const state = await storage.getItem<{ enabled: boolean }>(
|
||||
getTranslationStateKey(tabId),
|
||||
)
|
||||
const isCurrentlyTranslated = state?.enabled ?? false
|
||||
const isCurrentlyTranslated = await getPageTranslationEnabled(tabId)
|
||||
const newState = !isCurrentlyTranslated
|
||||
|
||||
// Update storage directly (instead of sending message to self)
|
||||
await storage.setItem(getTranslationStateKey(tabId), { enabled: newState })
|
||||
if (!newState) {
|
||||
await setPageTranslationEnabled(tabId, false)
|
||||
void sendMessage("notifyTranslationStateChanged", { enabled: false }, tabId)
|
||||
}
|
||||
|
||||
// Notify content script in that specific tab
|
||||
void sendMessage("askManagerToTogglePageTranslation", {
|
||||
|
|
@ -212,7 +212,7 @@ async function handleTranslateClick(tabId: number) {
|
|||
}, tabId)
|
||||
|
||||
// Update menu title immediately
|
||||
await updateTranslateMenuTitle(tabId)
|
||||
await updateTranslateMenuTitle(tabId, newState)
|
||||
}
|
||||
|
||||
async function handleSelectionTranslateClick(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
import type { FrameInfoForSiteControl } from "./iframe-injection-utils"
|
||||
import type { Config } from "@/types/config/config"
|
||||
import { browser } from "#imports"
|
||||
import { getLocalConfig } from "@/utils/config/storage"
|
||||
import { logger } from "@/utils/logger"
|
||||
import { isSiteEnabled, SITE_CONTROL_URL_WINDOW_KEY } from "@/utils/site-control"
|
||||
import { resolveSiteControlUrl } from "./iframe-injection-utils"
|
||||
import { getPageTranslationEnabled } from "./page-translation-state"
|
||||
|
||||
const pendingDocumentKeys = new Set<string>()
|
||||
const injectedDocumentKeysByFrame = new Map<string, string>()
|
||||
|
||||
function getDocumentInjectionKey(details: { tabId: number, frameId: number, documentId?: string }) {
|
||||
// Gracefully skip document-level deduplication when Chromium does not expose documentId.
|
||||
if (!details.documentId) {
|
||||
return null
|
||||
}
|
||||
interface FrameInjectionDetails {
|
||||
tabId: number
|
||||
frameId: number
|
||||
documentId?: string
|
||||
parentFrameId?: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
return `${details.tabId}:${details.frameId}:${details.documentId}`
|
||||
function getDocumentInjectionKey(details: FrameInjectionDetails) {
|
||||
// documentId is best, but getAllFrames may not expose it. Fall back to URL so
|
||||
// explicit per-tab injections still dedupe until the frame navigates.
|
||||
return `${details.tabId}:${details.frameId}:${details.documentId ?? details.url ?? "unknown"}`
|
||||
}
|
||||
|
||||
function getFrameInjectionKey(details: { tabId: number, frameId: number }) {
|
||||
|
|
@ -63,7 +71,7 @@ function setInjectedSiteControlUrl(propertyName: string, siteControlUrl: string)
|
|||
;(globalThis as Record<string, unknown>)[propertyName] = siteControlUrl
|
||||
}
|
||||
|
||||
function getInjectionTarget(details: { tabId: number, frameId: number, documentId?: string }) {
|
||||
function getInjectionTarget(details: FrameInjectionDetails) {
|
||||
if (details.documentId) {
|
||||
return { tabId: details.tabId, documentIds: [details.documentId] }
|
||||
}
|
||||
|
|
@ -71,6 +79,119 @@ function getInjectionTarget(details: { tabId: number, frameId: number, documentI
|
|||
return { tabId: details.tabId, frameIds: [details.frameId] }
|
||||
}
|
||||
|
||||
async function getFrameSnapshot(tabId: number): Promise<FrameInfoForSiteControl[]> {
|
||||
return await browser.webNavigation.getAllFrames({ tabId }) ?? []
|
||||
}
|
||||
|
||||
async function injectHostContentIntoFrame(
|
||||
details: FrameInjectionDetails,
|
||||
frames?: FrameInfoForSiteControl[],
|
||||
existingConfig?: Config | null,
|
||||
) {
|
||||
const frameKey = getFrameInjectionKey(details)
|
||||
const documentKey = getDocumentInjectionKey(details)
|
||||
|
||||
if (
|
||||
pendingDocumentKeys.has(documentKey)
|
||||
|| injectedDocumentKeysByFrame.get(frameKey) === documentKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDocumentKeys.add(documentKey)
|
||||
|
||||
try {
|
||||
let siteControlUrl: string | undefined
|
||||
|
||||
try {
|
||||
const [config, frameSnapshot] = await Promise.all([
|
||||
existingConfig === undefined ? getLocalConfig() : Promise.resolve(existingConfig),
|
||||
frames === undefined ? getFrameSnapshot(details.tabId) : Promise.resolve(frames),
|
||||
])
|
||||
const liveFrameIds = new Set(frameSnapshot.map(frame => frame.frameId))
|
||||
liveFrameIds.add(details.frameId)
|
||||
pruneInjectedFrames(details.tabId, liveFrameIds)
|
||||
|
||||
siteControlUrl = resolveSiteControlUrl(
|
||||
details.frameId,
|
||||
details.url,
|
||||
frameSnapshot,
|
||||
getParentFrameIdHint(details),
|
||||
)
|
||||
|
||||
if (!siteControlUrl || !isSiteEnabled(siteControlUrl, config)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("[Background][IframeInjection] Failed to resolve iframe injection prerequisites", error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const target = getInjectionTarget(details) as Parameters<typeof browser.scripting.executeScript>[0]["target"]
|
||||
|
||||
await browser.scripting.executeScript({
|
||||
target,
|
||||
func: setInjectedSiteControlUrl,
|
||||
args: [SITE_CONTROL_URL_WINDOW_KEY, siteControlUrl],
|
||||
})
|
||||
|
||||
await browser.scripting.executeScript({
|
||||
target,
|
||||
files: ["/content-scripts/host.js"],
|
||||
})
|
||||
|
||||
injectedDocumentKeysByFrame.set(frameKey, documentKey)
|
||||
}
|
||||
catch (error) {
|
||||
logger.warn("[Background][IframeInjection] Failed to inject iframe content scripts", error)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
pendingDocumentKeys.delete(documentKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectHostContentIntoTabIframes(tabId: number) {
|
||||
let isEnabled: boolean
|
||||
try {
|
||||
isEnabled = await getPageTranslationEnabled(tabId)
|
||||
}
|
||||
catch (error) {
|
||||
logger.warn("[Background][IframeInjection] Failed to read page translation state", error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEnabled)
|
||||
return
|
||||
|
||||
let config: Config | null
|
||||
let frames: FrameInfoForSiteControl[]
|
||||
try {
|
||||
[config, frames] = await Promise.all([
|
||||
getLocalConfig(),
|
||||
getFrameSnapshot(tabId),
|
||||
])
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("[Background][IframeInjection] Failed to resolve tab iframe injection prerequisites", error)
|
||||
return
|
||||
}
|
||||
|
||||
const liveFrameIds = new Set(frames.map(frame => frame.frameId))
|
||||
pruneInjectedFrames(tabId, liveFrameIds)
|
||||
|
||||
await Promise.all(frames
|
||||
.filter(frame => frame.frameId !== 0)
|
||||
.map(frame => injectHostContentIntoFrame({
|
||||
tabId,
|
||||
frameId: frame.frameId,
|
||||
parentFrameId: frame.parentFrameId,
|
||||
url: frame.url,
|
||||
}, frames, config)))
|
||||
}
|
||||
|
||||
export function setupIframeInjection() {
|
||||
browser.tabs.onRemoved.addListener(clearTabDocumentState)
|
||||
browser.webNavigation.onBeforeNavigate.addListener((details) => {
|
||||
|
|
@ -82,86 +203,24 @@ export function setupIframeInjection() {
|
|||
clearFrameInjectedDocumentState(details.tabId, details.frameId)
|
||||
})
|
||||
|
||||
// Listen for iframe loads and inject content scripts programmatically
|
||||
// This catches iframes that Chrome's manifest-based all_frames: true misses
|
||||
// (e.g., dynamically created iframes, sandboxed iframes like edX)
|
||||
// Only inject into subframes after page translation is enabled for the tab.
|
||||
// This keeps iframe-heavy pages and benchmarks from paying content-script cost
|
||||
// before the feature is actually used.
|
||||
browser.webNavigation.onCompleted.addListener(async (details) => {
|
||||
// Skip main frame (frameId === 0), only handle iframes
|
||||
if (details.frameId === 0)
|
||||
return
|
||||
|
||||
const frameKey = getFrameInjectionKey(details)
|
||||
const documentKey = getDocumentInjectionKey(details)
|
||||
if (documentKey) {
|
||||
if (
|
||||
pendingDocumentKeys.has(documentKey)
|
||||
|| injectedDocumentKeysByFrame.get(frameKey) === documentKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDocumentKeys.add(documentKey)
|
||||
}
|
||||
|
||||
try {
|
||||
let siteControlUrl: string | undefined
|
||||
|
||||
try {
|
||||
const config = await getLocalConfig()
|
||||
const frames = await browser.webNavigation.getAllFrames({ tabId: details.tabId }) ?? []
|
||||
const liveFrameIds = new Set(frames.map(frame => frame.frameId))
|
||||
liveFrameIds.add(details.frameId)
|
||||
pruneInjectedFrames(details.tabId, liveFrameIds)
|
||||
|
||||
siteControlUrl = resolveSiteControlUrl(
|
||||
details.frameId,
|
||||
details.url,
|
||||
frames,
|
||||
getParentFrameIdHint(details),
|
||||
)
|
||||
|
||||
if (!siteControlUrl || !isSiteEnabled(siteControlUrl, config)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("[Background][IframeInjection] Failed to resolve iframe injection prerequisites", error)
|
||||
const isEnabled = await getPageTranslationEnabled(details.tabId)
|
||||
if (!isEnabled)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const target = getInjectionTarget(details) as Parameters<typeof browser.scripting.executeScript>[0]["target"]
|
||||
|
||||
await browser.scripting.executeScript({
|
||||
target,
|
||||
func: setInjectedSiteControlUrl,
|
||||
args: [SITE_CONTROL_URL_WINDOW_KEY, siteControlUrl],
|
||||
})
|
||||
|
||||
// Inject host.content script into the iframe
|
||||
await browser.scripting.executeScript({
|
||||
target,
|
||||
files: ["/content-scripts/host.js"],
|
||||
})
|
||||
|
||||
// Inject selection.content script into the iframe
|
||||
await browser.scripting.executeScript({
|
||||
target,
|
||||
files: ["/content-scripts/selection.js"],
|
||||
})
|
||||
|
||||
if (documentKey) {
|
||||
injectedDocumentKeysByFrame.set(frameKey, documentKey)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.warn("[Background][IframeInjection] Failed to inject iframe content scripts", error)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (documentKey) {
|
||||
pendingDocumentKeys.delete(documentKey)
|
||||
}
|
||||
catch (error) {
|
||||
logger.warn("[Background][IframeInjection] Failed to read page translation state", error)
|
||||
return
|
||||
}
|
||||
|
||||
await injectHostContentIntoFrame(details)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export default defineBackground({
|
|||
setupTTSPlaybackMessageHandlers()
|
||||
void initMockData()
|
||||
|
||||
// Setup programmatic injection for iframes that Chrome's manifest-based all_frames misses
|
||||
// Setup on-demand iframe injection after page translation is enabled.
|
||||
setupIframeInjection()
|
||||
},
|
||||
})
|
||||
|
|
|
|||
17
src/entrypoints/background/page-translation-state.ts
Normal file
17
src/entrypoints/background/page-translation-state.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TranslationState } from "@/types/translation-state"
|
||||
import { storage } from "#imports"
|
||||
import { getTranslationStateKey } from "@/utils/constants/storage-keys"
|
||||
|
||||
export async function getPageTranslationEnabled(tabId: number): Promise<boolean> {
|
||||
const state = await storage.getItem<TranslationState>(
|
||||
getTranslationStateKey(tabId),
|
||||
)
|
||||
return state?.enabled ?? false
|
||||
}
|
||||
|
||||
export async function setPageTranslationEnabled(tabId: number, enabled: boolean): Promise<void> {
|
||||
await storage.setItem<TranslationState>(
|
||||
getTranslationStateKey(tabId),
|
||||
{ enabled },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FeatureUsageContext } from "@/types/analytics"
|
||||
import type { Config } from "@/types/config/config"
|
||||
import type { TranslationState } from "@/types/translation-state"
|
||||
import { browser, storage } from "#imports"
|
||||
import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics"
|
||||
import { createFeatureUsageContext } from "@/utils/analytics"
|
||||
|
|
@ -8,6 +8,22 @@ import { getTranslationStateKey } from "@/utils/constants/storage-keys"
|
|||
import { shouldEnableAutoTranslation } from "@/utils/host/translate/auto-translation"
|
||||
import { logger } from "@/utils/logger"
|
||||
import { onMessage, sendMessage } from "@/utils/message"
|
||||
import { injectHostContentIntoTabIframes } from "./iframe-injection"
|
||||
import { getPageTranslationEnabled, setPageTranslationEnabled } from "./page-translation-state"
|
||||
|
||||
function notifyPageTranslationStateChanged(tabId: number, enabled: boolean) {
|
||||
void sendMessage("notifyTranslationStateChanged", { enabled }, tabId)
|
||||
.catch(error => logger.warn("Failed to notify page translation state change", error))
|
||||
}
|
||||
|
||||
function requestManagerToTogglePageTranslation(
|
||||
tabId: number,
|
||||
enabled: boolean,
|
||||
analyticsContext?: FeatureUsageContext,
|
||||
) {
|
||||
void sendMessage("askManagerToTogglePageTranslation", { enabled, analyticsContext }, tabId)
|
||||
.catch(error => logger.warn("Failed to ask page translation manager to toggle", error))
|
||||
}
|
||||
|
||||
export function translationMessage() {
|
||||
onMessage("getEnablePageTranslationByTabId", async (msg) => {
|
||||
|
|
@ -26,7 +42,11 @@ export function translationMessage() {
|
|||
|
||||
onMessage("tryToSetEnablePageTranslationByTabId", async (msg) => {
|
||||
const { tabId, enabled, analyticsContext } = msg.data
|
||||
void sendMessage("askManagerToTogglePageTranslation", { enabled, analyticsContext }, tabId)
|
||||
if (!enabled) {
|
||||
await setPageTranslationEnabled(tabId, false)
|
||||
notifyPageTranslationStateChanged(tabId, false)
|
||||
}
|
||||
requestManagerToTogglePageTranslation(tabId, enabled, analyticsContext)
|
||||
})
|
||||
|
||||
onMessage("tryToSetEnablePageTranslationOnContentScript", async (msg) => {
|
||||
|
|
@ -34,7 +54,11 @@ export function translationMessage() {
|
|||
const { enabled, analyticsContext } = msg.data
|
||||
if (typeof tabId === "number") {
|
||||
logger.info("sending tryToSetEnablePageTranslationOnContentScript to manager", { enabled, tabId })
|
||||
await sendMessage("askManagerToTogglePageTranslation", { enabled, analyticsContext }, tabId)
|
||||
if (!enabled) {
|
||||
await setPageTranslationEnabled(tabId, false)
|
||||
notifyPageTranslationStateChanged(tabId, false)
|
||||
}
|
||||
requestManagerToTogglePageTranslation(tabId, enabled, analyticsContext)
|
||||
}
|
||||
else {
|
||||
logger.error("tabId is not a number", msg)
|
||||
|
|
@ -50,10 +74,11 @@ export function translationMessage() {
|
|||
return
|
||||
const shouldEnable = await shouldEnableAutoTranslation(url, detectedCodeOrUnd, config)
|
||||
if (shouldEnable) {
|
||||
void sendMessage("askManagerToTogglePageTranslation", {
|
||||
enabled: true,
|
||||
analyticsContext: createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.PAGE_AUTO),
|
||||
}, tabId)
|
||||
requestManagerToTogglePageTranslation(
|
||||
tabId,
|
||||
true,
|
||||
createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.PAGE_AUTO),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -62,11 +87,13 @@ export function translationMessage() {
|
|||
const tabId = msg.sender?.tab?.id
|
||||
const { enabled } = msg.data
|
||||
if (typeof tabId === "number") {
|
||||
await storage.setItem<TranslationState>(
|
||||
getTranslationStateKey(tabId),
|
||||
{ enabled },
|
||||
)
|
||||
void sendMessage("notifyTranslationStateChanged", { enabled }, tabId)
|
||||
await setPageTranslationEnabled(tabId, enabled)
|
||||
notifyPageTranslationStateChanged(tabId, enabled)
|
||||
|
||||
const senderFrameId = msg.sender?.frameId
|
||||
if (enabled && (senderFrameId === 0 || senderFrameId === undefined)) {
|
||||
void injectHostContentIntoTabIframes(tabId)
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.error("tabId is not a number", msg)
|
||||
|
|
@ -75,10 +102,7 @@ export function translationMessage() {
|
|||
|
||||
// === Helper Functions ===
|
||||
async function getTranslationState(tabId: number): Promise<boolean> {
|
||||
const state = await storage.getItem<TranslationState>(
|
||||
getTranslationStateKey(tabId),
|
||||
)
|
||||
return state?.enabled ?? false
|
||||
return await getPageTranslationEnabled(tabId)
|
||||
}
|
||||
|
||||
// === Cleanup ===
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ declare global {
|
|||
export default defineContentScript({
|
||||
matches: ["*://*/*", "file:///*"],
|
||||
cssInjectionMode: "manual",
|
||||
allFrames: true,
|
||||
async main(ctx) {
|
||||
// Prevent double injection (manifest-based + programmatic injection)
|
||||
if (window.__READ_FROG_HOST_INJECTED__)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
|
|||
enabled ? void manager.start(window === window.top ? analyticsContext : undefined) : manager.stop()
|
||||
})
|
||||
|
||||
const cleanupFrameTranslationStateListener = window === window.top
|
||||
? () => {}
|
||||
: onMessage("notifyTranslationStateChanged", (msg) => {
|
||||
const { enabled } = msg.data
|
||||
if (enabled === manager.isActive)
|
||||
return
|
||||
enabled ? void manager.start() : manager.stop()
|
||||
})
|
||||
|
||||
ctx.onInvalidated(() => {
|
||||
removeHostToast()
|
||||
cleanupUrlListener()
|
||||
|
|
@ -85,6 +94,7 @@ export async function bootstrapHostContent(ctx: ContentScriptContext, initialCon
|
|||
cleanupPageTranslationTriggers()
|
||||
cleanupTranslationShortcut()
|
||||
cleanupTranslationStateListener()
|
||||
cleanupFrameTranslationStateListener()
|
||||
window.removeEventListener("extension:URLChange", handleExtensionUrlChange)
|
||||
window.__READ_FROG_HOST_INJECTED__ = false
|
||||
clearEffectiveSiteControlUrl()
|
||||
|
|
|
|||
|
|
@ -22,23 +22,17 @@ vi.mock("@/components/ui/json-code-editor", () => ({
|
|||
JSONCodeEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
onBlur?: () => void
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="provider-options-editor"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onBlur={onBlur}
|
||||
onChange={event => onChange?.(event.target.value)}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
|
@ -59,22 +53,16 @@ const baseProviderConfig: APIProviderConfig = {
|
|||
function ProviderOptionsFieldHarness({
|
||||
initialConfig,
|
||||
externalProviderOptions,
|
||||
submitDelayMs = 0,
|
||||
}: {
|
||||
initialConfig: APIProviderConfig
|
||||
externalProviderOptions?: Record<string, unknown>
|
||||
submitDelayMs?: number
|
||||
}) {
|
||||
const [providerConfig, setProviderConfig] = useState(initialConfig)
|
||||
const form = useAppForm({
|
||||
...formOpts,
|
||||
defaultValues: providerConfig,
|
||||
onSubmit: async ({ value }) => {
|
||||
if (submitDelayMs > 0) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
|
||||
}
|
||||
|
||||
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
|
||||
setProviderConfig(value)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -117,7 +105,6 @@ describe("providerOptionsField", () => {
|
|||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -128,29 +115,6 @@ describe("providerOptionsField", () => {
|
|||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
|
||||
})
|
||||
|
||||
it("keeps focused draft edits when a delayed autosave echo arrives", async () => {
|
||||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} submitDelayMs={100} />)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"low\"}" } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"low\"}")
|
||||
})
|
||||
|
||||
it("shows the matched recommended provider options as the placeholder when the value is empty", () => {
|
||||
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
|
||||
|
||||
|
|
@ -216,9 +180,7 @@ describe("providerOptionsField", () => {
|
|||
)
|
||||
|
||||
const editor = screen.getByLabelText("provider-options-editor")
|
||||
fireEvent.focus(editor)
|
||||
fireEvent.change(editor, { target: { value: "{" } })
|
||||
fireEvent.blur(editor)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
|
||||
await Promise.resolve()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export const ProviderOptionsField = withForm({
|
|||
const [jsonInput, setJsonInput] = useState(() => externalJson)
|
||||
const lastCommittedJsonRef = useRef(externalJson)
|
||||
const pendingEditorCommitRef = useRef(false)
|
||||
const editorFocusedRef = useRef(false)
|
||||
|
||||
const syncJsonInput = useEffectEvent((nextJson: string) => {
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
|
|
@ -56,18 +55,6 @@ export const ProviderOptionsField = withForm({
|
|||
return jsonInput
|
||||
})
|
||||
|
||||
const handleJsonInputChange = useCallback((nextJson: string) => {
|
||||
setJsonInput(nextJson)
|
||||
}, [])
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
editorFocusedRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleEditorBlur = useCallback(() => {
|
||||
editorFocusedRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
resetSyncStateForProvider()
|
||||
}, [providerConfig.id])
|
||||
|
|
@ -79,15 +66,9 @@ export const ProviderOptionsField = withForm({
|
|||
}
|
||||
|
||||
pendingEditorCommitRef.current = false
|
||||
|
||||
const currentJsonInput = readJsonInput()
|
||||
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedJsonRef.current = externalJson
|
||||
|
||||
if (currentJsonInput !== externalJson) {
|
||||
if (readJsonInput() !== externalJson) {
|
||||
syncJsonInput(externalJson)
|
||||
}
|
||||
}, [providerConfig.providerOptions, externalJson])
|
||||
|
|
@ -146,9 +127,7 @@ export const ProviderOptionsField = withForm({
|
|||
</FieldLabel>
|
||||
<JSONCodeEditor
|
||||
value={jsonInput}
|
||||
onChange={handleJsonInputChange}
|
||||
onFocus={handleEditorFocus}
|
||||
onBlur={handleEditorBlur}
|
||||
onChange={setJsonInput}
|
||||
placeholder={placeholderText}
|
||||
hasError={!!jsonError}
|
||||
height="150px"
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ async function mountSelectionUI(ctx: ContentScriptContext) {
|
|||
export default defineContentScript({
|
||||
matches: ["*://*/*", "file:///*"],
|
||||
cssInjectionMode: "ui",
|
||||
allFrames: true,
|
||||
async main(ctx) {
|
||||
// Prevent double injection (manifest-based + programmatic injection)
|
||||
if (window.__READ_FROG_SELECTION_INJECTED__)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue