The AITURK Chrome extension now registers the user's Chrome as the desktop backend's browser controller, the way the ChatGPT extension lets its agent work in the user's browser. The agent's existing browser tools (navigate, snapshot, click, type, scroll, back, press, get_images, vision) run in its own background tabs grouped as "AITURK", or a tab the user hands over. Backend: a local desktop lane in the browser-control broker. One controller registered from the loopback desktop transport is offered to every desktop session as a lane template; each session still gets its own exact scope on first use. The lane has its own switch (browser.extension_control.local_desktop, on by default); with no extension registered, or after it disconnects, the normal browser backend runs unchanged. browser_vision turns the extension's capture into the usual native/auxiliary vision result. Bridge: forwards controller commands only to a connection that registered, refuses unknown or oversized commands, relays results. Extension: service-worker controller with its own native connection, trusted chrome.debugger input (press/release only: a hover move waits 5 s on background tabs), refusal of password and payment-card fields and of browser, extension, file and Web Store pages, panel switch, Stop, tab hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK". Cancelling Chrome's debugging bar stops the agent. Existing panel flows and privacy rules are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
647 lines
30 KiB
JavaScript
647 lines
30 KiB
JavaScript
// Lets the AITURK IDE agent use this Chrome, the way the ChatGPT extension
|
||
// lets its agent work in the user's browser. Runs in the service worker and
|
||
// keeps its own native connection, so it works with the side panel closed.
|
||
//
|
||
// Boundaries: the agent works only in its own tabs (grouped as "AITURK") or a
|
||
// tab the user explicitly hands over; never on chrome://, extension, file or
|
||
// Web Store pages; never types into password or payment-card fields. Chrome
|
||
// shows its own "debugging" bar while the agent is in control; cancelling it,
|
||
// the panel's Stop button or the switch turns control off at once.
|
||
|
||
import {
|
||
annotateRefs, clickWithEvents, listImages, locateRef, prepareTyping, readValue, scrollPage, snapshotPage, typeWithEvents,
|
||
} from './page-agent.js'
|
||
|
||
export const HOST = 'online.turkservis.aiturk'
|
||
export const CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click', 'browser_type',
|
||
'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
|
||
const COMMAND_BUDGET_MS = 27000
|
||
const LOAD_WAIT_MS = 20000
|
||
const DEBUGGER_IDLE_MS = 45000
|
||
const SCREENSHOT_LIMIT = 380000
|
||
const LOG_SIZE = 30
|
||
|
||
export class ToolError extends Error {}
|
||
|
||
const KEY_ALIASES = { esc: 'Escape', return: 'Enter', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
|
||
space: ' ', spacebar: ' ', del: 'Delete', pgup: 'PageUp', pgdn: 'PageDown', ctrl: 'Control', cmd: 'Meta', command: 'Meta', option: 'Alt' }
|
||
const NAMED_KEYS = { Enter: [13, 'Enter', '\r'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
|
||
Delete: [46, 'Delete'], ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], ArrowLeft: [37, 'ArrowLeft'],
|
||
ArrowRight: [39, 'ArrowRight'], Home: [36, 'Home'], End: [35, 'End'], PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
|
||
' ': [32, 'Space', ' '], F5: [116, 'F5'] }
|
||
const MODIFIERS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }
|
||
|
||
// "Control+a" → one CDP key description; unknown names fail before any input.
|
||
export function parseKey(value) {
|
||
const parts = String(value || '').split('+').map(part => part.trim()).filter(Boolean)
|
||
if (!parts.length || parts.length > 4) throw new ToolError('Tuş adı geçersiz. Örnek: Enter, Tab, Escape, Control+a')
|
||
let modifiers = 0
|
||
const keyName = parts.pop()
|
||
for (const raw of parts) {
|
||
const name = KEY_ALIASES[raw.toLowerCase()] || raw[0].toUpperCase() + raw.slice(1).toLowerCase()
|
||
if (!MODIFIERS[name]) throw new ToolError(`Bilinmeyen değiştirici tuş: ${raw}`)
|
||
modifiers |= MODIFIERS[name]
|
||
}
|
||
const alias = KEY_ALIASES[keyName.toLowerCase()]
|
||
const name = alias || (keyName.length === 1 ? keyName : keyName[0].toUpperCase() + keyName.slice(1))
|
||
const named = NAMED_KEYS[name] || NAMED_KEYS[Object.keys(NAMED_KEYS).find(key => key.toLowerCase() === name.toLowerCase())]
|
||
if (named) {
|
||
const [code, keyCode, text] = named
|
||
return { key: name === ' ' ? ' ' : keyCode, code: keyCode === ' ' ? 'Space' : keyCode, windowsVirtualKeyCode: code,
|
||
text: modifiers & ~MODIFIERS.Shift ? undefined : text, modifiers }
|
||
}
|
||
if (name.length !== 1) throw new ToolError(`Bilinmeyen tuş: ${keyName}`)
|
||
const upper = name.toUpperCase()
|
||
const letter = /[A-Z]/.test(upper), digit = /[0-9]/.test(name)
|
||
return { key: name, code: letter ? `Key${upper}` : digit ? `Digit${name}` : '',
|
||
windowsVirtualKeyCode: letter || digit ? upper.charCodeAt(0) : name.charCodeAt(0),
|
||
text: modifiers & ~MODIFIERS.Shift ? undefined : name, modifiers }
|
||
}
|
||
|
||
// Only ordinary web pages. Local development servers are allowed; browser,
|
||
// extension, file and Web Store pages are not.
|
||
export function checkUrl(value, { allowBlank = false } = {}) {
|
||
let text = String(value || '').trim()
|
||
if (!text) throw new ToolError('Adres boş.')
|
||
if (!/^[a-z][a-z0-9+.-]*:/i.test(text)) text = `https://${text}`
|
||
let url
|
||
try { url = new URL(text) } catch { throw new ToolError(`Geçersiz adres: ${String(value).slice(0, 200)}`) }
|
||
if (allowBlank && url.href === 'about:blank') return url.href
|
||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||
throw new ToolError(`Ajan bu tür sayfada çalışamaz (${url.protocol}). Yalnız http/https sayfaları açılabilir.`)
|
||
}
|
||
if (/^(chrome\.google\.com|chromewebstore\.google\.com)$/i.test(url.hostname) &&
|
||
(url.hostname.startsWith('chromewebstore') || url.pathname.startsWith('/webstore'))) {
|
||
throw new ToolError('Chrome Web Mağazası sayfalarında ajan çalışamaz.')
|
||
}
|
||
if (url.username || url.password) throw new ToolError('Adreste kullanıcı adı/şifre olamaz.')
|
||
return url.href
|
||
}
|
||
|
||
const shortHost = value => { try { return new URL(value).host } catch { return '' } }
|
||
|
||
function describeCommand(action, args) {
|
||
switch (action) {
|
||
case 'browser_navigate': return `Sayfa açılıyor: ${shortHost(args.url) || String(args.url || '').slice(0, 60)}`
|
||
case 'browser_snapshot': return 'Sayfa okunuyor'
|
||
case 'browser_click': return `Tıklanıyor: ${String(args.ref || '').slice(0, 20)}`
|
||
case 'browser_type': return `Yazılıyor: ${String(args.ref || '').slice(0, 20)}`
|
||
case 'browser_scroll': return args.direction === 'up' ? 'Yukarı kaydırılıyor' : 'Aşağı kaydırılıyor'
|
||
case 'browser_back': return 'Geri gidiliyor'
|
||
case 'browser_press': return `Tuşa basılıyor: ${String(args.key || '').slice(0, 20)}`
|
||
case 'browser_get_images': return 'Görseller listeleniyor'
|
||
case 'browser_vision': return 'Ekran görüntüsü alınıyor'
|
||
default: return action
|
||
}
|
||
}
|
||
|
||
export function createController({ chrome, setTimer = setTimeout, clearTimer = clearTimeout, now = () => Date.now(), random = () => crypto.randomUUID() }) {
|
||
let port = null, connected = false, registered = false, lastError = '', profile = ''
|
||
let enabled = true, instanceId = '', settingsLoaded = null, reconnectTimer = null, backoff = 2000
|
||
let nextId = 0, titles = new Map(), titlesAt = 0
|
||
const pending = new Map()
|
||
const active = new Map()
|
||
const queues = new Map()
|
||
const debuggers = new Map()
|
||
let state = null
|
||
|
||
// ── persistent pieces ────────────────────────────────────────────────
|
||
async function settings() {
|
||
settingsLoaded ||= (async () => {
|
||
const saved = await chrome.storage.local.get(['controlEnabled', 'controllerInstance'])
|
||
enabled = saved.controlEnabled !== false
|
||
instanceId = saved.controllerInstance || random()
|
||
if (!saved.controllerInstance) await chrome.storage.local.set({ controllerInstance: instanceId })
|
||
})()
|
||
return settingsLoaded
|
||
}
|
||
|
||
async function load() {
|
||
if (state) return state
|
||
const saved = await chrome.storage.session.get('controlState')
|
||
state = { tabs: {}, groups: {}, shared: null, log: [], ...(saved.controlState || {}) }
|
||
return state
|
||
}
|
||
|
||
async function save() { await chrome.storage.session.set({ controlState: state }) }
|
||
|
||
// ── native connection ────────────────────────────────────────────────
|
||
function request(action, params = {}) {
|
||
if (!port) return Promise.reject(new Error('IDE bağlantısı yok.'))
|
||
const id = `c${++nextId}`
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimer(() => { pending.delete(id); reject(new Error('IDE yanıt vermedi.')) }, 30000)
|
||
pending.set(id, { resolve, reject, timer })
|
||
try { port.postMessage({ id, action, params }) }
|
||
catch (error) { clearTimer(timer); pending.delete(id); reject(error) }
|
||
})
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (!enabled || reconnectTimer) return
|
||
reconnectTimer = setTimer(() => { reconnectTimer = null; void connect() }, backoff)
|
||
backoff = Math.min(backoff * 2, 60000)
|
||
chrome.alarms?.create('aiturk-reconnect', { periodInMinutes: 1 })
|
||
}
|
||
|
||
async function connect() {
|
||
await settings()
|
||
if (!enabled || port) return
|
||
const current = chrome.runtime.connectNative(HOST)
|
||
port = current
|
||
current.onMessage.addListener(frame => onFrame(current, frame))
|
||
current.onDisconnect.addListener(() => {
|
||
if (port !== current) return
|
||
port = null; connected = false; registered = false
|
||
for (const item of pending.values()) { clearTimer(item.timer); item.reject(new Error('IDE bağlantısı kapandı.')) }
|
||
pending.clear()
|
||
abortAll('IDE bağlantısı kapandı.')
|
||
lastError ||= 'AITURK IDE kapalı ya da Chrome bağlantısı kurulmamış.'
|
||
broadcast()
|
||
scheduleReconnect()
|
||
})
|
||
try {
|
||
const status = await request('status')
|
||
profile = status?.profile || ''
|
||
connected = true; lastError = ''
|
||
await register()
|
||
backoff = 2000
|
||
chrome.alarms?.clear('aiturk-reconnect')
|
||
} catch (error) {
|
||
lastError = error.message
|
||
}
|
||
broadcast()
|
||
}
|
||
|
||
async function register() {
|
||
if (!port || !enabled) return
|
||
const result = await request('browser.register', { enabled: true, capabilities: [...CAPABILITIES],
|
||
controllerId: `aiturk-chrome-${instanceId}`, browserProfileId: instanceId })
|
||
registered = Boolean(result?.registered)
|
||
}
|
||
|
||
function onFrame(source, frame) {
|
||
if (source !== port || !frame || typeof frame !== 'object') return
|
||
if (frame.id !== undefined) {
|
||
const item = pending.get(frame.id)
|
||
if (!item) return
|
||
pending.delete(frame.id); clearTimer(item.timer)
|
||
frame.error ? item.reject(new Error(frame.error)) : item.resolve(frame.result)
|
||
return
|
||
}
|
||
if (frame.event === 'browser.command') void runCommand(frame)
|
||
else if (frame.event === 'browser.cancel') cancelCommand(frame.commandId, 'Ajan bu adımı iptal etti.')
|
||
else if (frame.event === 'disconnected') {
|
||
// The IDE restarted its backend; the native host is still here.
|
||
registered = false; connected = false
|
||
abortAll('IDE yeniden bağlanıyor.')
|
||
broadcast()
|
||
setTimer(() => { void reconnectBackend() }, 2000)
|
||
} else if (frame.event === 'error') { lastError = String(frame.message || '').slice(0, 300); broadcast() }
|
||
}
|
||
|
||
async function reconnectBackend() {
|
||
if (!port || !enabled) return
|
||
try {
|
||
const status = await request('status')
|
||
profile = status?.profile || ''
|
||
connected = true; lastError = ''
|
||
await register()
|
||
} catch (error) { lastError = error.message; setTimer(() => { void reconnectBackend() }, 10000) }
|
||
broadcast()
|
||
}
|
||
|
||
// ── commands ─────────────────────────────────────────────────────────
|
||
async function runCommand(frame) {
|
||
const { commandId, action, sessionId } = frame
|
||
const args = frame.arguments && typeof frame.arguments === 'object' ? frame.arguments : {}
|
||
const record = { commandId, cancelled: false, reason: '' }
|
||
active.set(commandId, record)
|
||
const entry = await logEntry({ at: now(), sessionId, action, text: describeCommand(action, args), status: 'running' })
|
||
let reply
|
||
try {
|
||
if (!CAPABILITIES.includes(action)) throw new ToolError('Chrome bu işlemi desteklemiyor.')
|
||
const result = await withBudget(execute(action, args, sessionId, record), COMMAND_BUDGET_MS)
|
||
reply = { commandId, ok: true, result }
|
||
entry.status = result?.success === false ? 'failed' : 'ok'
|
||
if (result?.success === false) entry.note = String(result.error || '').slice(0, 200)
|
||
} catch (error) {
|
||
const message = String(error?.message || error).slice(0, 2000)
|
||
// Expected refusals reach the model as a normal tool result it can act on.
|
||
reply = error instanceof ToolError ? { commandId, ok: true, result: { success: false, error: message } }
|
||
: { commandId, ok: false, error: message }
|
||
entry.status = 'failed'; entry.note = message.slice(0, 200)
|
||
}
|
||
if (record.cancelled) { entry.status = 'cancelled'; entry.note = record.reason }
|
||
active.delete(commandId)
|
||
await save().catch(() => {})
|
||
broadcast()
|
||
if (record.cancelled) return
|
||
try { await request('browser.result', reply) } catch {}
|
||
}
|
||
|
||
function cancelCommand(commandId, reason) {
|
||
const record = active.get(commandId)
|
||
if (record) { record.cancelled = true; record.reason = reason }
|
||
}
|
||
|
||
function abortAll(reason) { for (const record of active.values()) { record.cancelled = true; record.reason = reason } }
|
||
|
||
function guard(record) { if (record?.cancelled) throw new ToolError(record.reason || 'Durduruldu.') }
|
||
|
||
async function withBudget(promise, ms) {
|
||
let timer
|
||
const timeout = new Promise((_, reject) => { timer = setTimer(() => reject(new ToolError('Tarayıcı adımı zaman aşımına uğradı.')), ms) })
|
||
try { return await Promise.race([promise, timeout]) } finally { clearTimer(timer) }
|
||
}
|
||
|
||
// One step at a time per tab. The stored tail never rejects: the caller
|
||
// handles the step's own error, the queue only orders the steps.
|
||
function serial(tabId, task) {
|
||
const next = (queues.get(tabId) || Promise.resolve()).then(task)
|
||
const tail = next.catch(() => {}).then(() => { if (queues.get(tabId) === tail) queues.delete(tabId) })
|
||
queues.set(tabId, tail)
|
||
return next
|
||
}
|
||
|
||
async function execute(action, args, sessionId, record) {
|
||
if (action === 'browser_navigate') {
|
||
const url = checkUrl(args.url)
|
||
const tab = await agentTab(sessionId, true)
|
||
return serial(tab.id, async () => {
|
||
guard(record)
|
||
await chrome.tabs.update(tab.id, { url })
|
||
const loaded = await waitForLoad(tab.id, LOAD_WAIT_MS)
|
||
guard(record)
|
||
const snap = await inject(tab.id, snapshotPage, [{ full: false }])
|
||
return { success: true, url: snap.url, title: snap.title, snapshot: snap.snapshot, element_count: snap.element_count,
|
||
...(loaded ? {} : { note: 'Sayfa yüklenmeye devam ediyor; gerekirse browser_snapshot ile tekrar bakın.' }) }
|
||
})
|
||
}
|
||
const tab = await agentTab(sessionId, false)
|
||
return serial(tab.id, async () => {
|
||
guard(record)
|
||
const current = await chrome.tabs.get(tab.id)
|
||
const address = current.pendingUrl || current.url || ''
|
||
if (address === 'about:blank' || !address) {
|
||
if (action === 'browser_snapshot') return { success: true, snapshot: 'Boş sekme. Önce browser_navigate ile bir sayfa açın.', element_count: 0, url: 'about:blank', title: '' }
|
||
throw new ToolError('Sekme boş. Önce browser_navigate ile bir sayfa açın.')
|
||
}
|
||
checkUrl(address)
|
||
switch (action) {
|
||
case 'browser_snapshot': {
|
||
const snap = await inject(tab.id, snapshotPage, [{ full: args.full === true }])
|
||
return { success: true, ...snap }
|
||
}
|
||
case 'browser_click': return click(tab, args.ref, record)
|
||
case 'browser_type': return type(tab, args.ref, typeof args.text === 'string' ? args.text : String(args.text ?? ''), record)
|
||
case 'browser_scroll': {
|
||
const result = await inject(tab.id, scrollPage, [args.direction === 'up' ? 'up' : 'down'])
|
||
return { success: true, direction: args.direction === 'up' ? 'up' : 'down', ...result }
|
||
}
|
||
case 'browser_back': {
|
||
try { await chrome.tabs.goBack(tab.id) } catch { throw new ToolError('Geri gidilecek sayfa yok.') }
|
||
await waitForLoad(tab.id, LOAD_WAIT_MS)
|
||
const after = await chrome.tabs.get(tab.id)
|
||
return { success: true, url: after.url, title: after.title }
|
||
}
|
||
case 'browser_press': return press(tab, args.key)
|
||
case 'browser_get_images': return { success: true, ...(await inject(tab.id, listImages, [])) }
|
||
case 'browser_vision': return vision(tab, args.annotate === true)
|
||
}
|
||
throw new ToolError('Chrome bu işlemi desteklemiyor.')
|
||
})
|
||
}
|
||
|
||
async function click(tab, ref, record) {
|
||
const located = await inject(tab.id, locateRef, [ref])
|
||
if (located.error) throw new ToolError(located.error)
|
||
if (located.disabled) throw new ToolError(`${ref} şu an devre dışı.`)
|
||
guard(record)
|
||
let trusted = true
|
||
try {
|
||
const send = await debuggerFor(tab.id)
|
||
// No mouseMoved first: on a background tab Chrome holds a hover move
|
||
// for 5 s waiting for a frame, while press/release go straight through.
|
||
for (const type of ['mousePressed', 'mouseReleased']) {
|
||
await send('Input.dispatchMouseEvent', { type, x: located.x, y: located.y, button: 'left', clickCount: 1 })
|
||
}
|
||
} catch {
|
||
trusted = false
|
||
const result = await inject(tab.id, clickWithEvents, [ref])
|
||
if (result.error) throw new ToolError(result.error)
|
||
}
|
||
await settle(tab.id)
|
||
const after = await chrome.tabs.get(tab.id)
|
||
return { success: true, clicked: ref, element: located.name, url: after.url, title: after.title,
|
||
...(located.covered ? { warning: `Tıklanan noktada başka bir öğe vardı: "${located.covered}". Gerekirse önce onu kapatın.` } : {}),
|
||
...(trusted ? {} : { note: 'Gerçek fare tıklaması yerine sayfa olayı kullanıldı.' }) }
|
||
}
|
||
|
||
async function type(tab, ref, text, record) {
|
||
const prepared = await inject(tab.id, prepareTyping, [ref])
|
||
if (prepared.error) {
|
||
if (prepared.handoff) notify('AITURK senin girmeni bekliyor', prepared.error)
|
||
throw new ToolError(prepared.error)
|
||
}
|
||
guard(record)
|
||
if (prepared.select) {
|
||
const result = await inject(tab.id, typeWithEvents, [ref, text])
|
||
if (result.error) throw new ToolError(result.error)
|
||
return { success: true, typed: ref, value: result.value }
|
||
}
|
||
try {
|
||
const send = await debuggerFor(tab.id)
|
||
if (text) await send('Input.insertText', { text })
|
||
else for (const type of ['rawKeyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 })
|
||
} catch {
|
||
const result = await inject(tab.id, typeWithEvents, [ref, text])
|
||
if (result.error) throw new ToolError(result.error)
|
||
}
|
||
const value = await inject(tab.id, readValue, [ref])
|
||
return { success: true, typed: ref, value: value.value ?? '' }
|
||
}
|
||
|
||
async function press(tab, key) {
|
||
const description = parseKey(key)
|
||
try {
|
||
const send = await debuggerFor(tab.id)
|
||
await send('Input.dispatchKeyEvent', { type: description.text ? 'keyDown' : 'rawKeyDown', ...description,
|
||
unmodifiedText: description.text })
|
||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: description.key, code: description.code,
|
||
windowsVirtualKeyCode: description.windowsVirtualKeyCode, modifiers: description.modifiers })
|
||
} catch {
|
||
throw new ToolError('Tuşa basılamadı: Chrome kontrol izni alınamadı (başka bir geliştirici aracı açık olabilir).')
|
||
}
|
||
await settle(tab.id)
|
||
const after = await chrome.tabs.get(tab.id)
|
||
return { success: true, pressed: String(key), url: after.url, title: after.title }
|
||
}
|
||
|
||
async function vision(tab, annotate) {
|
||
let annotations = []
|
||
if (annotate) {
|
||
await inject(tab.id, snapshotPage, [{ full: false }])
|
||
annotations = (await inject(tab.id, annotateRefs, [true])).annotations || []
|
||
}
|
||
try {
|
||
let data = ''
|
||
try {
|
||
const send = await debuggerFor(tab.id)
|
||
// A background tab paints on demand for the capture (about 1-3 s).
|
||
for (const options of [{ quality: 65, budget: 15000 }, { quality: 40, budget: 5000 }, { quality: 40, scale: 0.6, budget: 5000 }]) {
|
||
let clip
|
||
if (options.scale) {
|
||
const metrics = await send('Page.getLayoutMetrics', {})
|
||
const view = metrics.cssVisualViewport || metrics.layoutViewport
|
||
clip = { x: view.pageX || 0, y: view.pageY || 0, width: view.clientWidth, height: view.clientHeight, scale: options.scale }
|
||
}
|
||
const shot = await withBudget(send('Page.captureScreenshot', { format: 'jpeg', quality: options.quality, ...(clip ? { clip } : {}) }), options.budget)
|
||
data = shot?.data || ''
|
||
if (data && data.length <= SCREENSHOT_LIMIT) break
|
||
}
|
||
} catch {
|
||
const current = await chrome.tabs.get(tab.id)
|
||
if (!current.active) throw new ToolError('Ekran görüntüsü alınamadı: ajanın sekmesi görünür değil ve Chrome kontrol izni alınamadı.')
|
||
const url = await chrome.tabs.captureVisibleTab(current.windowId, { format: 'jpeg', quality: 55 })
|
||
data = String(url || '').replace(/^data:image\/jpeg;base64,/, '')
|
||
}
|
||
if (!data || data.length > SCREENSHOT_LIMIT) throw new ToolError('Ekran görüntüsü çok büyük ya da boş geldi.')
|
||
const after = await chrome.tabs.get(tab.id)
|
||
return { success: true, screenshot: data, mime: 'image/jpeg', url: after.url, title: after.title,
|
||
...(annotate ? { annotations } : {}) }
|
||
} finally {
|
||
if (annotate) await inject(tab.id, annotateRefs, [false]).catch(() => {})
|
||
}
|
||
}
|
||
|
||
// ── tabs ─────────────────────────────────────────────────────────────
|
||
async function agentTab(sessionId, create) {
|
||
const current = await load()
|
||
const key = sessionId || 'default'
|
||
const known = current.tabs[key]
|
||
if (known) {
|
||
try { return await chrome.tabs.get(known) } catch { delete current.tabs[key] }
|
||
}
|
||
if (current.shared && (!current.shared.sessionId || current.shared.sessionId === key)) {
|
||
try {
|
||
const tab = await chrome.tabs.get(current.shared.tabId)
|
||
current.tabs[key] = tab.id
|
||
current.shared = null
|
||
await save()
|
||
return tab
|
||
} catch { current.shared = null }
|
||
}
|
||
if (!create) throw new ToolError('Bu konuşma için açık bir tarayıcı sekmesi yok. Önce browser_navigate ile bir sayfa açın.')
|
||
const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }).catch(() => null)
|
||
const tab = await chrome.tabs.create({ url: 'about:blank', active: false, ...(window?.id ? { windowId: window.id } : {}) })
|
||
current.tabs[key] = tab.id
|
||
await save()
|
||
await groupTab(tab, key).catch(() => {})
|
||
return tab
|
||
}
|
||
|
||
async function sessionTitle(sessionId) {
|
||
if (now() - titlesAt > 60000 && port) {
|
||
titlesAt = now()
|
||
try {
|
||
const data = await request('sessions.list')
|
||
titles = new Map((data?.sessions || []).map(row => [row.id, row.title]))
|
||
} catch {}
|
||
}
|
||
return titles.get(sessionId) || ''
|
||
}
|
||
|
||
async function groupTab(tab, key) {
|
||
const current = await load()
|
||
let groupId = current.groups[key]
|
||
if (groupId !== undefined) {
|
||
try {
|
||
const group = await chrome.tabGroups.get(groupId)
|
||
if (group.windowId !== tab.windowId) groupId = undefined
|
||
} catch { groupId = undefined }
|
||
}
|
||
groupId = await chrome.tabs.group({ tabIds: [tab.id], ...(groupId === undefined ? { createProperties: { windowId: tab.windowId } } : { groupId }) })
|
||
current.groups[key] = groupId
|
||
await save()
|
||
const title = await sessionTitle(key)
|
||
await chrome.tabGroups.update(groupId, { title: `AITURK${title ? ` · ${title.slice(0, 28)}` : ''}`, color: 'cyan' })
|
||
}
|
||
|
||
function waitForLoad(tabId, ms) {
|
||
return new Promise(resolve => {
|
||
let done = false
|
||
const finish = value => {
|
||
if (done) return
|
||
done = true; clearTimer(timer)
|
||
chrome.tabs.onUpdated.removeListener(listener)
|
||
resolve(value)
|
||
}
|
||
const listener = (id, change) => { if (id === tabId && change.status === 'complete') finish(true) }
|
||
const timer = setTimer(() => finish(false), ms)
|
||
chrome.tabs.onUpdated.addListener(listener)
|
||
// A page that finished before the listener existed.
|
||
// pendingUrl is set until the new document commits, so an old page's
|
||
// "complete" is never mistaken for the new one.
|
||
setTimer(() => { chrome.tabs.get(tabId).then(tab => { if (tab.status === 'complete' && !tab.pendingUrl) finish(true) }).catch(() => finish(false)) }, 250)
|
||
})
|
||
}
|
||
|
||
async function settle(tabId) {
|
||
await new Promise(resolve => setTimer(resolve, 400))
|
||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||
if (tab?.status === 'loading') await waitForLoad(tabId, 8000)
|
||
}
|
||
|
||
async function inject(tabId, func, args) {
|
||
let results
|
||
try {
|
||
results = await chrome.scripting.executeScript({ target: { tabId }, func, args })
|
||
} catch (error) {
|
||
throw new ToolError(`Sayfaya erişilemedi: ${String(error?.message || error).slice(0, 200)}`)
|
||
}
|
||
const value = results?.[0]?.result
|
||
if (value === undefined || value === null) throw new ToolError('Sayfa yanıt vermedi; yükleniyor olabilir.')
|
||
return value
|
||
}
|
||
|
||
async function debuggerFor(tabId) {
|
||
let entry = debuggers.get(tabId)
|
||
if (!entry) {
|
||
await chrome.debugger.attach({ tabId }, '1.3')
|
||
entry = { timer: null }
|
||
debuggers.set(tabId, entry)
|
||
}
|
||
clearTimer(entry.timer)
|
||
entry.timer = setTimer(() => { void detachDebugger(tabId) }, DEBUGGER_IDLE_MS)
|
||
return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params)
|
||
}
|
||
|
||
async function detachDebugger(tabId) {
|
||
const entry = debuggers.get(tabId)
|
||
if (!entry) return
|
||
clearTimer(entry.timer)
|
||
debuggers.delete(tabId)
|
||
await chrome.debugger.detach({ tabId }).catch(() => {})
|
||
}
|
||
|
||
// ── state for the panel ─────────────────────────────────────────────
|
||
async function logEntry(entry) {
|
||
const current = await load()
|
||
current.log.unshift(entry)
|
||
current.log.length = Math.min(current.log.length, LOG_SIZE)
|
||
await save().catch(() => {})
|
||
broadcast()
|
||
return entry
|
||
}
|
||
|
||
function notify(title, message) {
|
||
chrome.notifications?.create({ type: 'basic', iconUrl: 'icon128.png', title, message: String(message).slice(0, 200), priority: 1 })
|
||
?.catch?.(() => {})
|
||
}
|
||
|
||
async function snapshotState() {
|
||
const current = await load()
|
||
const tabs = []
|
||
for (const [sessionId, tabId] of Object.entries(current.tabs)) {
|
||
try {
|
||
const tab = await chrome.tabs.get(tabId)
|
||
tabs.push({ sessionId, tabId, title: (tab.title || '').slice(0, 120), url: shortHost(tab.url) })
|
||
} catch { delete current.tabs[sessionId] }
|
||
}
|
||
return { enabled, connected, registered, error: lastError, profile, running: active.size,
|
||
log: current.log.slice(0, 15), tabs, shared: current.shared }
|
||
}
|
||
|
||
function broadcast() {
|
||
chrome.action?.setBadgeText?.({ text: active.size ? '●' : '' })?.catch?.(() => {})
|
||
void snapshotState().then(view => chrome.runtime.sendMessage({ type: 'control.state', state: view })).catch(() => {})
|
||
}
|
||
|
||
async function setEnabled(value) {
|
||
await settings()
|
||
enabled = value === true
|
||
await chrome.storage.local.set({ controlEnabled: enabled })
|
||
if (enabled) {
|
||
backoff = 2000
|
||
if (port && !registered) { try { await register() } catch (error) { lastError = error.message } }
|
||
else await connect()
|
||
} else {
|
||
abortAll('Kullanıcı ajanın tarayıcı kullanımını kapattı.')
|
||
for (const tabId of [...debuggers.keys()]) await detachDebugger(tabId)
|
||
if (port) { try { await request('browser.register', { enabled: false }) } catch {} }
|
||
registered = false
|
||
const previous = port
|
||
port = null; connected = false
|
||
previous?.disconnect()
|
||
chrome.alarms?.clear('aiturk-reconnect')
|
||
}
|
||
broadcast()
|
||
return snapshotState()
|
||
}
|
||
|
||
async function shareTab(tab, sessionId) {
|
||
if (!tab?.id) throw new ToolError('Paylaşılacak sekme bulunamadı.')
|
||
checkUrl(tab.url || '')
|
||
const current = await load()
|
||
if (sessionId) current.tabs[sessionId] = tab.id
|
||
else current.shared = { tabId: tab.id, sessionId: null, title: (tab.title || '').slice(0, 120) }
|
||
await save()
|
||
broadcast()
|
||
return snapshotState()
|
||
}
|
||
|
||
async function releaseTab(sessionId) {
|
||
const current = await load()
|
||
const ids = sessionId ? [current.tabs[sessionId]] : Object.values(current.tabs)
|
||
if (sessionId) delete current.tabs[sessionId]
|
||
else { current.tabs = {}; current.shared = null }
|
||
for (const tabId of ids) if (tabId) await detachDebugger(tabId)
|
||
await save()
|
||
broadcast()
|
||
return snapshotState()
|
||
}
|
||
|
||
async function onMessage(message, sender) {
|
||
switch (message?.type) {
|
||
case 'control.get':
|
||
await settings()
|
||
if (enabled && !port) void connect()
|
||
return snapshotState()
|
||
case 'control.set': return setEnabled(message.enabled === true)
|
||
case 'control.stop': return setEnabled(false)
|
||
case 'control.share': {
|
||
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
|
||
return shareTab(tab, typeof message.sessionId === 'string' ? message.sessionId : null)
|
||
}
|
||
case 'control.release': return releaseTab(typeof message.sessionId === 'string' ? message.sessionId : null)
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function onDebuggerDetach(source, reason) {
|
||
const entry = debuggers.get(source?.tabId)
|
||
if (!entry) return
|
||
clearTimer(entry.timer)
|
||
debuggers.delete(source.tabId)
|
||
if (reason === 'canceled_by_user') {
|
||
notify('AITURK durduruldu', 'Chrome uyarısındaki İptal ile ajanın tarayıcı kullanımı kapatıldı. Panelden tekrar açabilirsiniz.')
|
||
void setEnabled(false)
|
||
}
|
||
}
|
||
|
||
async function onTabRemoved(tabId) {
|
||
const current = await load()
|
||
let changed = false
|
||
for (const [key, value] of Object.entries(current.tabs)) if (value === tabId) { delete current.tabs[key]; changed = true }
|
||
if (current.shared?.tabId === tabId) { current.shared = null; changed = true }
|
||
debuggers.delete(tabId)
|
||
if (changed) { await save(); broadcast() }
|
||
}
|
||
|
||
return { connect, onMessage, onDebuggerDetach, onTabRemoved, setEnabled, shareTab, releaseTab, snapshotState,
|
||
onAlarm: name => { if (name === 'aiturk-reconnect') void connect() },
|
||
// exposed for tests
|
||
_execute: execute, _runCommand: runCommand, _state: () => ({ port, connected, registered, enabled, active }) }
|
||
}
|