Add Windows Chrome side panel and scoped IDE native bridge

This commit is contained in:
yilsem committed 2026-09-07 12:45:45 +03:00
1 parent 75afd01b4b
commit fb6f57cd36
14 files changed
+1018 -1

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join } from 'node:path'
// This is a capability bridge, not a general-purpose gateway proxy. Neither
// backend credentials nor filesystem/RPC access are exposed to the browser.
export const MAX_FRAME = 512 * 1024
const MAX_TEXT = 48000
const clip = (value, limit = 8000) => typeof value === 'string' ? value.slice(0, limit) : ''
const contentText = value => typeof value === 'string' ? value : Array.isArray(value)
? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : ''
export function publicMessages(rows) {
return (Array.isArray(rows) ? rows : []).filter(row =>
['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden'
).slice(-40).map(row => ({
id: row.id ?? row.row_id,
role: row.role,
text: clip(contentText(row.display_content ?? row.content ?? row.text)),
}))
}
export function validateRequest(message) {
if (!message || typeof message.id !== 'string' || message.id.length > 80 ||
!['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action)) {
throw new Error('Desteklenmeyen eklenti isteği.')
}
const params = message.params || {}
if (['session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action) &&
(typeof params.sessionId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.sessionId))) {
throw new Error('Geçersiz konuşma kimliği.')
}
if (message.action === 'message.send' &&
(typeof params.text !== 'string' || !params.text.trim() || params.text.length > MAX_TEXT)) {
throw new Error('Mesaj boş olamaz ve 48.000 karakteri aşamaz.')
}
return params
}
export class ChromeGatewayClient {
constructor(resolveBackend, emit, webSocket = globalThis.WebSocket) {
this.resolveBackend = resolveBackend
this.emit = emit
this.WebSocket = webSocket
this.pending = new Map()
this.nextId = 0
this.selected = null
this.closed = false
}
async connect() {
if (this.closed) throw new Error('Bağlantı kapandı. Yeniden bağlanın.')
if (this.socket?.readyState === 1) return
const backend = await this.resolveBackend()
const url = new URL(backend.baseUrl)
const wsUrl = new URL(backend.wsUrl)
if (backend.mode === 'remote' || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' ||
wsUrl.protocol !== 'ws:' || wsUrl.hostname !== '127.0.0.1' || wsUrl.port !== url.port || !backend.token) {
throw new Error('Chrome beta yalnızca bu bilgisayardaki yerel IDE bağlantısını destekler.')
}
if (this.closed) throw new Error('Bağlantı kapandı.')
this.backend = backend
const socket = new this.WebSocket(backend.wsUrl)
this.socket = socket
socket.addEventListener('message', event => {
let frame
try { frame = JSON.parse(String(event.data)) } catch { return }
if (frame.id !== undefined) {
const pending = this.pending.get(frame.id)
if (!pending) return
this.pending.delete(frame.id)
clearTimeout(pending.timer)
if (frame.error) pending.reject(new Error(clip(frame.error.message, 1000) || 'IDE isteği başarısız.'))
else pending.resolve(frame.result)
} else if (frame.method === 'event') this.onEvent(frame.params)
})
socket.addEventListener('close', () => {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(new Error('IDE bağlantısı kesildi. Gönderimi tekrar denemeden önce geçmişi kontrol edin.'))
}
this.pending.clear()
this.selected = null
if (!this.closed) this.emit({ event: 'disconnected', message: 'IDE bağlantısı kesildi. Yeniden bağlanın.' })
})
await new Promise((resolve, reject) => {
const timer = setTimeout(() => { socket.close(); reject(new Error('IDE bağlantısı zaman aşımına uğradı.')) }, 15000)
socket.addEventListener('open', () => { clearTimeout(timer); resolve() }, { once: true })
socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('IDE bağlantısı kurulamadı.')) }, { once: true })
})
}
rpc(method, params) {
const id = ++this.nextId
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error('IDE yanıtı beklenirken süre doldu. Gönderimi tekrar denemeden önce geçmişi kontrol edin.'))
}, 60000)
this.pending.set(id, { resolve, reject, timer })
try { this.socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) }
catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error) }
})
}
async rest(path) {
const response = await fetch(`${this.backend.baseUrl}${path}`, {
headers: { 'X-Hermes-Session-Token': this.backend.token },
signal: AbortSignal.timeout(30000),
redirect: 'error',
})
if (!response.ok) throw new Error(`IDE verisi alınamadı (${response.status}).`)
return response.json()
}
scope() {
return this.backend.profile ? { profile: this.backend.profile } : {}
}
async history(sessionId) {
const query = new URLSearchParams({ ...this.scope(), limit: '40', order: 'latest', include_compacted: 'true' })
const page = await this.rest(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${query}`)
return { sessionId, messages: publicMessages(page.messages), limited: true }
}
onEvent(event) {
const selected = this.selected
if (!selected || event?.session_id !== selected.runtimeId) return
const payload = event.payload || {}
const type = event.type
if (['message.start', 'message.delta', 'message.complete', 'session.error', 'error'].includes(type)) {
if (type === 'message.start') selected.running = true
if (type === 'message.complete') selected.running = false
this.emit({ event: type, sessionId: selected.storedId,
text: clip(payload.text || payload.message, 16000), status: clip(payload.status, 100) })
} else if (['approval.request', 'clarify.request', 'sudo.request'].includes(type)) {
this.emit({ event: 'attention', sessionId: selected.storedId,
message: 'Bu konuşma IDE’de onay veya ek bilgi bekliyor. Devam etmek için IDE’ye geçin.' })
} else if (type === 'tool.start') {
this.emit({ event: 'activity', sessionId: selected.storedId, message: `Araç çalışıyor: ${clip(payload.name || payload.tool, 100) || 'IDE aracı'}` })
}
}
async handle(message) {
const params = validateRequest(message)
await this.connect()
switch (message.action) {
case 'status':
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
case 'sessions.list': {
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
const data = await this.rest(`/api/sessions?${query}`)
const rows = Array.isArray(data) ? data : data.sessions || []
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
})), profile: this.backend.profile || 'default', limited: true }
}
case 'session.open': {
// Clear foreground first: late events from the previous task cannot leak.
this.selected = null
const result = await this.rpc('session.resume', { session_id: params.sessionId,
cols: 96, source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
if (!result.session_id) throw new Error('IDE konuşma kimliğini döndürmedi.')
this.selected = { storedId: params.sessionId, runtimeId: result.session_id, running: Boolean(result.running) }
return { ...await this.history(params.sessionId), running: Boolean(result.running),
model: clip(result.info?.model, 160), inflight: clip(result.inflight?.assistant, 16000),
attention: Boolean(result.pending_approval || result.pending_clarify) }
}
case 'session.history':
return this.history(params.sessionId)
case 'message.send':
case 'message.stop': {
if (this.selected?.storedId !== params.sessionId) throw new Error('Önce bu konuşmayı seçin.')
if (message.action === 'message.stop') {
await this.rpc('session.interrupt', { session_id: this.selected.runtimeId })
return { stopped: true }
}
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true })
return { accepted: true }
}
}
}
close() {
this.closed = true
this.socket?.close()
}
}
export async function startChromeBridge({ directory, resolveBackend }) {
await mkdir(directory, { recursive: true })
const token = randomBytes(32).toString('hex')
const pipe = `aiturk-chrome-${randomBytes(16).toString('hex')}`
const discovery = join(directory, 'chrome-bridge.json')
const sockets = new Set()
const server = createServer(socket => {
if (sockets.size >= 8) { socket.destroy(); return }
sockets.add(socket)
let buffer = ''
let authenticated = false
let queued = 0
let chain = Promise.resolve()
const timer = setTimeout(() => socket.destroy(), 5000)
const send = frame => {
if (socket.destroyed) return
const data = JSON.stringify(frame)
if (Buffer.byteLength(data) > MAX_FRAME || socket.writableLength > MAX_FRAME * 2) {
socket.destroy(); return
}
socket.write(data + '\n')
}
const client = new ChromeGatewayClient(resolveBackend, send)
socket.setEncoding('utf8')
socket.on('data', chunk => {
buffer += chunk
if (Buffer.byteLength(buffer) > MAX_FRAME) { socket.destroy(); return }
let end
while ((end = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, end)
buffer = buffer.slice(end + 1)
let message
try { message = JSON.parse(line) } catch { socket.destroy(); return }
if (!authenticated) {
if (typeof message.token !== 'string' || message.token.length !== token.length ||
!timingSafeEqual(Buffer.from(message.token), Buffer.from(token))) { socket.destroy(); return }
authenticated = true
clearTimeout(timer)
continue
}
if (++queued > 12) { socket.destroy(); return }
chain = chain.then(async () => {
if (socket.destroyed) return
try { send({ id: message.id, result: await client.handle(message) }) }
catch (error) { send({ id: message.id, error: clip(error.message, 1200) || 'IDE bağlantısı başarısız.' }) }
}).finally(() => { queued-- })
}
})
socket.on('error', () => {})
socket.on('close', () => { clearTimeout(timer); sockets.delete(socket); client.close() })
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(`\\\\.\\pipe\\${pipe}`, resolve)
})
const temporary = `${discovery}.${process.pid}.tmp`
await writeFile(temporary, JSON.stringify({ protocol: 1, pipe, token }), { mode: 0o600 })
await rename(temporary, discovery)
return async () => {
for (const socket of sockets) socket.destroy()
server.close()
// A newer IDE instance may have replaced discovery; never remove its file.
try {
if (JSON.parse(await readFile(discovery, 'utf8')).token === token) await unlink(discovery)
} catch {}
}
}
+14
View File
@@ -7,6 +7,7 @@ import os from 'node:os'
import path from 'node:path'
import tls from 'node:tls'
import { pathToFileURL } from 'node:url'
import { startChromeBridge } from './chrome-bridge.mjs'
import {
app,
@@ -17740,6 +17741,19 @@ app.on('open-url', (event, url) => {
})
app.whenReady().then(() => {
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
void startChromeBridge({
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
resolveBackend: async () => {
const profile = primaryProfileKey()
if (globalRemoteActive() || profileHasRemoteOverride(profile)) {
throw new Error('Chrome beta için IDE’de yerel bağlantıyı seçin.')
}
return ensureBackend(profile)
}
}).then(stop => app.once('will-quit', () => { void stop() }))
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
}
// Warm the login-shell PATH resolution immediately so it usually completes
// before the backend start path awaits the same single-flight promise.
void ensureLoginShellPath()