feat(ext): handcraft the whole ext from scratch

AI coding doesn't work for MV3 extensions.
Threading was an unfixable mess.
Removed everything and rebuilt by hand.
This commit is contained in:
Simon
2026-01-27 17:21:32 +08:00
parent 8efa8e18c1
commit fdc3cf4e6d
18 changed files with 797 additions and 1749 deletions

View File

@@ -1,114 +1,44 @@
/**
* Background Script (Service Worker) - Stateless Message Relay
*
* Completely stateless. Only two responsibilities:
* 1. Relay AGENT_TO_PAGE messages from SidePanel to ContentScript
* 2. Broadcast TAB_CHANGE events to all extension pages
*/
import {
type AgentToPageMessage,
type TabChangeMessage,
isExtensionMessage,
} from '../agent/protocol'
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage } from '@/agent/TabsController.background'
// ============================================================================
// Message Relay
// ============================================================================
function handleUtilsMessage(
message: { type: 'UTILS'; action: string; payload: any },
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
): boolean {
const { action, payload } = message
chrome.runtime.onMessage.addListener(
(
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void
): boolean => {
if (!isExtensionMessage(message)) {
switch (action) {
case 'get_tab_info': {
chrome.tabs
.get(payload.tabId)
.then((tab) => {
const result = { title: tab.title || '', url: tab.url || '' }
sendResponse(result)
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
default:
sendResponse({ error: `Unknown TAB_CONTROL action: ${action}` })
return false
}
if (message.type === 'AGENT_TO_PAGE') {
handleAgentToPage(message as AgentToPageMessage, sendResponse)
return true // Async response
}
}
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'TAB_CONTROL') {
return handleTabControlMessage(message, sender, sendResponse)
} else if (message.type === 'PAGE_CONTROL') {
return handlePageControlMessage(message, sender, sendResponse)
} else if (message.type !== 'UTILS') {
return handleUtilsMessage(message, sender, sendResponse)
} else {
sendResponse({ error: 'Unknown message type' })
return false
}
)
/**
* Forward RPC call from SidePanel to ContentScript
*/
async function handleAgentToPage(
msg: AgentToPageMessage,
sendResponse: (response: { success: boolean; result?: unknown; error?: string }) => void
): Promise<void> {
const { tabId, method, args } = msg
try {
// Forward directly to content script, same message format
const result = await chrome.tabs.sendMessage(tabId, msg)
sendResponse({ success: true, result })
} catch (error) {
sendResponse({
success: false,
error: error instanceof Error ? error.message : String(error),
})
}
}
// ============================================================================
// Tab Event Broadcasting
// ============================================================================
function broadcastTabChange(message: TabChangeMessage): void {
chrome.runtime.sendMessage(message).catch(() => {
// No listeners (sidepanel not open)
})
}
chrome.tabs.onRemoved.addListener((tabId) => {
broadcastTabChange({
type: 'TAB_CHANGE',
eventType: 'removed',
tabId,
})
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (!changeInfo.status) return
broadcastTabChange({
type: 'TAB_CHANGE',
eventType: 'updated',
tabId,
data: {
status: changeInfo.status,
url: changeInfo.url,
},
})
})
chrome.tabs.onActivated.addListener((activeInfo) => {
broadcastTabChange({
type: 'TAB_CHANGE',
eventType: 'activated',
tabId: activeInfo.tabId,
data: {
windowId: activeInfo.windowId,
},
})
})
chrome.windows.onFocusChanged.addListener((windowId) => {
const focused = windowId !== chrome.windows.WINDOW_ID_NONE
broadcastTabChange({
type: 'TAB_CHANGE',
eventType: 'windowFocusChanged',
tabId: -1,
data: {
windowId: focused ? windowId : undefined,
focused,
},
})
})
// ============================================================================

View File

@@ -1,14 +1,4 @@
/**
* Content Script Entry Point
*
* Runs in web page context, hosts PageController.
* - Receives AGENT_TO_PAGE messages and responds via sendResponse
* - Polls chrome.storage to manage mask visibility (no outgoing messages)
*/
import { PageController } from '@page-agent/page-controller'
import type { AgentState, AgentToPageMessage } from '../agent/protocol'
import { isExtensionMessage } from '../agent/protocol'
import { initPageController } from '@/agent/RemotePageController.content'
const DEBUG_PREFIX = '[Content]'
@@ -16,163 +6,8 @@ export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_idle',
async main() {
main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
// Lazy-initialized controller
let controller: PageController | null = null
let initError: Error | null = null
let myTabId: number | null = null
function getController(): PageController {
if (initError) throw initError
if (!controller) {
try {
controller = new PageController({ enableMask: true })
console.debug(`${DEBUG_PREFIX} PageController created`)
} catch (error) {
initError = error instanceof Error ? error : new Error(String(error))
throw initError
}
}
return controller
}
// Register message handler
chrome.runtime.onMessage.addListener(
(
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void
): boolean => {
if (!isExtensionMessage(message)) return false
if (message.type !== 'AGENT_TO_PAGE') return false
const msg = message as AgentToPageMessage
// Cache our tab ID from the first message
if (myTabId === null) {
myTabId = msg.tabId
console.debug(`${DEBUG_PREFIX} Tab ID: ${myTabId}`)
}
handleRPC(msg.method, msg.args, getController, () => controller)
.then(sendResponse)
.catch((error) => {
console.error(`${DEBUG_PREFIX} RPC ${msg.method} failed:`, error)
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // Async response
}
)
// Start mask polling
startMaskPolling(
() => myTabId,
getController,
() => controller
)
// Cleanup on unload
window.addEventListener('beforeunload', () => {
controller?.dispose()
controller = null
})
initPageController()
},
})
/**
* Poll storage every second to manage mask visibility.
* Content script is autonomous - decides mask state based on:
* - agentState in storage (tabId, running)
* - document.visibilityState
*/
function startMaskPolling(
getTabId: () => number | null,
getController: () => PageController,
getControllerIfExists: () => PageController | null
): void {
let maskVisible = false
const poll = async () => {
const tabId = getTabId()
if (tabId === null) return // Don't know our tab ID yet
try {
const { agentState } = (await chrome.storage.local.get('agentState')) as {
agentState?: AgentState
}
const shouldShow =
agentState?.running === true &&
agentState?.tabId === tabId &&
document.visibilityState === 'visible'
if (shouldShow && !maskVisible) {
await getController().showMask()
maskVisible = true
} else if (!shouldShow && maskVisible) {
await getControllerIfExists()?.hideMask()
maskVisible = false
}
} catch {
// Storage access failed, ignore
}
}
setInterval(poll, 1000)
// Also poll on visibility change for faster response
document.addEventListener('visibilitychange', poll)
}
/**
* Handle RPC method call
*/
async function handleRPC(
method: string,
args: unknown[],
getController: () => PageController,
getControllerIfExists: () => PageController | null
): Promise<unknown> {
switch (method) {
case 'getCurrentUrl':
return getController().getCurrentUrl()
case 'getLastUpdateTime':
return getController().getLastUpdateTime()
case 'getBrowserState':
return getController().getBrowserState()
case 'updateTree':
return getController().updateTree()
case 'cleanUpHighlights':
await getControllerIfExists()?.cleanUpHighlights()
return undefined
case 'clickElement':
return getController().clickElement(args[0] as number)
case 'inputText':
return getController().inputText(args[0] as number, args[1] as string)
case 'selectOption':
return getController().selectOption(args[0] as number, args[1] as string)
case 'scroll':
return getController().scroll(args[0] as Parameters<PageController['scroll']>[0])
case 'scrollHorizontally':
return getController().scrollHorizontally(
args[0] as Parameters<PageController['scrollHorizontally']>[0]
)
case 'executeJavascript':
return getController().executeJavascript(args[0] as string)
default:
throw new Error(`Unknown RPC method: ${method}`)
}
}

View File

@@ -3,27 +3,26 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { LLMConfig } from '@/utils'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/utils/constants'
import type { LLMConfig } from '../../../agent/AgentController'
interface ConfigPanelProps {
config: LLMConfig
config: LLMConfig | null
onSave: (config: LLMConfig) => Promise<void>
onClose: () => void
}
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [apiKey, setApiKey] = useState(config.apiKey || DEMO_API_KEY)
const [baseURL, setBaseURL] = useState(config.baseURL || DEMO_BASE_URL)
const [model, setModel] = useState(config.model || DEMO_MODEL)
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
const [model, setModel] = useState(config?.model || DEMO_MODEL)
const [saving, setSaving] = useState(false)
// Update local state when config prop changes
useEffect(() => {
setApiKey(config.apiKey || DEMO_API_KEY)
setBaseURL(config.baseURL || DEMO_BASE_URL)
setModel(config.model || DEMO_MODEL)
setApiKey(config?.apiKey || DEMO_API_KEY)
setBaseURL(config?.baseURL || DEMO_BASE_URL)
setModel(config?.model || DEMO_MODEL)
}, [config])
const handleSave = async () => {