diff --git a/apps/chrome/KURULUM.txt b/apps/chrome/KURULUM.txt index 24a1aa4..7f40420 100644 --- a/apps/chrome/KURULUM.txt +++ b/apps/chrome/KURULUM.txt @@ -1,7 +1,7 @@ AITURK Chrome — Windows beta 0.1.0 GEREKSİNİMLER -Windows 10/11, Chrome 116 veya üzeri, Chrome köprüsü içeren AITURK IDE. +Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.7 veya üzeri. Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır. İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez. diff --git a/apps/chrome/README.md b/apps/chrome/README.md new file mode 100644 index 0000000..a7e31b9 --- /dev/null +++ b/apps/chrome/README.md @@ -0,0 +1,52 @@ +# AITURK Chrome beta + +Windows / Chrome MV3 side panel for existing local AITURK IDE conversations. +Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup. + +The extension talks only to `online.turkservis.aiturk` using Chrome native +messaging. The per-user .NET Framework host authenticates to Electron's random +named pipe with a separate capability from the user-local discovery file. +Electron retains the gateway token, allows only six semantic operations and +reuses the existing local backend. It resumes the stored conversation, maps its +runtime identity, then submits only the new prompt. No parallel agent backend, +history reconstruction, model API key in the browser or telemetry is involved. + +Only the active local profile is supported. The list searches titles among +desktop sessions in the most recent 200 records. The transcript shows the last +40 records, with up to 8,000 characters per visible message; the model's existing +context is not truncated by this display policy. Approvals and clarifications +remain in the IDE. A backend event is stamped once and delivered to every +attached viewer so the desktop can still show and answer these requests. + +## Build and verification + +From the repository root, on Windows: + +```powershell +./scripts/build-chrome.ps1 -OutputDirectory C:/path/to/new-output-directory +$env:AITURK_TEST_NATIVE_HOST = 'C:/path/printed/by/build/AITURK Chrome Kur.exe' +node --test apps/chrome/tests/bridge.test.mjs +``` + +The build uses the Windows .NET Framework C# compiler, not a downloaded binary. +`AITURK_TEST_NATIVE_HOST` enables the real framing / named-pipe / HTTP / WebSocket +integration test against a disposable fixture, without installing registry +entries or invoking a paid model. Without it the native host test is skipped. + +UI tests require the existing desktop workspace's jsdom dependency: + +```powershell +npm ci --workspace apps/desktop --include-workspace-root +node --test apps/chrome/tests/panel.test.mjs +``` + +Run backend fan-out and replay tests with the canonical hermetic runner: + +```bash +scripts/run_tests.sh -j 1 --paths tests/tui_gateway/test_chrome_viewer_fanout.py:tests/test_tui_gateway_event_replay.py +``` + +Publish an IDE installer from the same clean commit (including the backend +change), the Chrome ZIP and their SHA-256 checksums together. Update the download +page only after the immutable release assets are available. This is an unsigned +developer-mode beta, not a Chrome Web Store or signed Windows release. diff --git a/apps/chrome/extension/panel.js b/apps/chrome/extension/panel.js index 2e3debd..8a4ffcb 100644 --- a/apps/chrome/extension/panel.js +++ b/apps/chrome/extension/panel.js @@ -3,6 +3,7 @@ import { capturePageContext, composePrompt } from './page-context.js' const $ = id => document.getElementById(id) let port, connected = false, sessions = [], selected = '', generation = 0, nextId = 0 let stream, attachment = null, sending = false, opening = false, connectionGeneration = 0 +let bufferedEvents = [], turnGeneration = 0 const pending = new Map() const notice = text => { $('notice').textContent = text; $('notice').hidden = !text } const activity = text => { $('activity').textContent = text } @@ -112,6 +113,7 @@ function paintHistory(messages) { async function openSession(id) { const intent = ++generation selected = id; opening = Boolean(id); stream = null + bufferedEvents = [] attachment = null; $('attachment').hidden = true $('message').value = ''; $('stop').hidden = true notice(''); controls() @@ -127,24 +129,31 @@ async function openSession(id) { if (data.attention) notice('Bu konuşma IDE’de onay veya ek bilgi bekliyor.') await chrome.storage.local.set({ selectedSession: id }) } catch (error) { if (intent === generation) { selected = ''; notice(error.message) } } - finally { if (intent === generation) { opening = false; controls() } } + finally { + if (intent === generation) { + opening = false; controls() + const events = bufferedEvents; bufferedEvents = [] + events.forEach(handleEvent) + } + } } async function reconcile() { - const intent = generation, sessionId = selected + const intent = generation, sessionId = selected, turn = turnGeneration try { const data = await request('session.history', { sessionId }) - if (intent === generation && sessionId === selected && !opening) paintHistory(data.messages) + if (intent === generation && sessionId === selected && !opening && !stream && turn === turnGeneration) paintHistory(data.messages) } catch (error) { if (intent === generation) notice(error.message) } } function handleEvent(frame) { if (frame.event === 'disconnected') { disconnect(frame.message); return } if (frame.event === 'error') { notice(frame.message); return } - if (frame.sessionId !== selected || opening) return + if (frame.sessionId !== selected) return + if (opening) { if (bufferedEvents.length < 2000) bufferedEvents.push(frame); return } const nearBottom = $('transcript').scrollHeight - $('transcript').scrollTop - $('transcript').clientHeight < 120 switch (frame.event) { - case 'message.start': stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break + case 'message.start': turnGeneration++; stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break case 'message.delta': stream ||= bubble('assistant', '', true) stream.textContent = (stream.textContent + frame.text).slice(-100000) @@ -169,14 +178,17 @@ $('composer').addEventListener('submit', async event => { const text = composePrompt(draft, attachment) const intent = generation, sessionId = selected sending = true; controls(); notice(''); activity('IDE’ye gönderiliyor…') + const optimistic = bubble('user', text); scrollEnd() try { await request('message.send', { sessionId, text }) if (intent !== generation) return // Keep server history authoritative; optimistic text is replaced on finish. - bubble('user', text); scrollEnd() $('message').value = ''; attachment = null; $('attachment').hidden = true $('stop').hidden = false; activity('Mesaj IDE’ye iletildi; iş sürüyorsa sıraya alınır.') - } catch (error) { notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.') } + } catch (error) { + optimistic.parentElement?.remove() + notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.') + } finally { sending = false; controls() } }) $('message').addEventListener('input', controls) diff --git a/apps/chrome/tests/bridge.test.mjs b/apps/chrome/tests/bridge.test.mjs index f3870e0..41a78fc 100644 --- a/apps/chrome/tests/bridge.test.mjs +++ b/apps/chrome/tests/bridge.test.mjs @@ -147,11 +147,13 @@ test('Windows named pipe requires separate capability, native host frames Turkis const stop = await startChromeBridge({ directory, resolveBackend: f.resolve }) t.after(stop) const discovery = JSON.parse(await readFile(join(directory, 'chrome-bridge.json'), 'utf8')) - const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`) - await once(bad, 'connect') - const closed = once(bad, 'close') - bad.write(JSON.stringify({ token: 'x'.repeat(64) }) + '\n') - await closed + for (const hello of [null, { token: 'x'.repeat(64) }, { token: 'ı'.repeat(64) }, {}]) { + const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`) + await once(bad, 'connect') + const closed = once(bad, 'close') + bad.write(JSON.stringify(hello) + '\n') + await closed + } const child = spawn(process.env.AITURK_TEST_NATIVE_HOST, ['chrome-extension://ikcplfhgapaabedillejmggjcabpkolp/'], { env: { ...process.env, LOCALAPPDATA: local }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) diff --git a/apps/chrome/tests/panel.test.mjs b/apps/chrome/tests/panel.test.mjs new file mode 100644 index 0000000..6eb6ca1 --- /dev/null +++ b/apps/chrome/tests/panel.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { test } from 'node:test' +import { capturePageContext } from '../extension/page-context.js' + +const { JSDOM } = createRequire(import.meta.url)('jsdom') +const eventHub = () => ({ listeners: [], addListener(callback) { this.listeners.push(callback) }, emit(value) { this.listeners.forEach(callback => callback(value)) } }) +const tick = () => new Promise(resolve => setImmediate(resolve)) +async function settle(condition) { + for (let count = 0; count < 100; count++) { if (condition()) return; await tick() } + assert.ok(condition(), 'UI did not settle') +} + +async function panel(t, respond) { + const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8') + const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' }) + const calls = [] + const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) { + calls.push(message) + queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port)) + } } + Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option, + chrome: { runtime: { connectNative: () => port }, storage: { local: { get: async () => ({}), set: async () => {} } } } }) + await import(`../extension/panel.js?test=${Math.random()}`) + t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] }) + const get = id => dom.window.document.getElementById(id) + await settle(() => !get('sessions').disabled) + return { dom, calls, port, get, async select(id) { + get('sessions').value = id + get('sessions').dispatchEvent(new dom.window.Event('change')) + await settle(() => !get('sessions').disabled) + } } +} + +function fixtureResponse(message, emit) { + const result = message.action === 'status' ? { profile: 'default' } + : message.action === 'sessions.list' ? { profile: 'default', sessions: [{ id: 'one', title: 'Birinci iş' }, { id: 'two', title: 'İkinci iş' }] } + : message.action === 'session.open' ? { messages: [{ role: 'user', text: ' önceden yazıldı' }], model: 'IDE modeli' } + : { messages: [] } + emit({ id: message.id, result }) +} + +test('panel safely renders real history and puts the submitted user before early streaming tokens', async t => { + const p = await panel(t, (message, emit) => { + if (message.action !== 'message.send') return fixtureResponse(message, emit) + emit({ event: 'message.start', sessionId: 'one' }) + emit({ event: 'message.delta', sessionId: 'one', text: 'İlk yanıt' }) + emit({ id: message.id, result: { accepted: true } }) + }) + await p.select('one') + assert.equal(p.get('transcript').querySelector('img'), null) + assert.ok(p.get('transcript').textContent.includes(' p.get('message').value === '') + const messages = [...p.get('transcript').querySelectorAll('.text')].map(node => node.textContent) + assert.deepEqual(messages.slice(-2), ['Buradan devam edelim', 'İlk yanıt']) + assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1) + assert.equal(p.calls.find(call => call.action === 'message.send').params.sessionId, 'one') +}) + +test('task switching drops old events; disconnect keeps draft and does not retry a mutation', async t => { + const p = await panel(t, (message, emit, port) => { + if (message.action === 'message.send') { port.onDisconnect.emit(); return } + fixtureResponse(message, emit) + }) + await p.select('one') + await p.select('two') + p.port.onMessage.emit({ event: 'message.delta', sessionId: 'one', text: 'YANLIŞ KONUŞMA' }) + assert.ok(!p.get('transcript').textContent.includes('YANLIŞ')) + p.get('message').value = 'Kaybolmasın' + p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true })) + await settle(() => p.get('notice').textContent.includes('Taslağınız korundu')) + assert.equal(p.get('message').value, 'Kaybolmasın') + assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1) + assert.equal(p.get('send').disabled, true) +}) + +test('events arriving while history opens are not silently lost', async t => { + const p = await panel(t, (message, emit) => { + if (message.action === 'session.open') { + emit({ event: 'message.start', sessionId: message.params.sessionId }) + emit({ event: 'message.delta', sessionId: message.params.sessionId, text: 'Açılırken gelen yanıt' }) + } + fixtureResponse(message, emit) + }) + await p.select('one') + assert.ok(p.get('transcript').textContent.includes('Açılırken gelen yanıt')) +}) + +test('explicit page capture excludes form values and strips URL credentials in query and fragment', t => { + const dom = new JSDOM('

Görünür içerik

', + { url: 'https://example.test/page?token=secret#private' }) + Object.assign(globalThis, { window: dom.window, document: dom.window.document, location: dom.window.location, + NodeFilter: dom.window.NodeFilter, getComputedStyle: dom.window.getComputedStyle }) + dom.window.HTMLElement.prototype.getClientRects = () => [{ width: 100 }] + t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'location', 'NodeFilter', 'getComputedStyle']) delete globalThis[key] }) + const context = capturePageContext() + assert.equal(context.text, 'Görünür içerik') + assert.equal(context.url, 'https://example.test/page') + assert.equal(context.selection, false) +}) diff --git a/apps/desktop/electron/chrome-bridge.mjs b/apps/desktop/electron/chrome-bridge.mjs index da71a50..071ab88 100644 --- a/apps/desktop/electron/chrome-bridge.mjs +++ b/apps/desktop/electron/chrome-bridge.mjs @@ -223,7 +223,7 @@ export async function startChromeBridge({ directory, resolveBackend }) { let message try { message = JSON.parse(line) } catch { socket.destroy(); return } if (!authenticated) { - if (typeof message.token !== 'string' || message.token.length !== token.length || + if (!message || typeof message.token !== 'string' || !/^[a-f0-9]{64}$/.test(message.token) || !timingSafeEqual(Buffer.from(message.token), Buffer.from(token))) { socket.destroy(); return } authenticated = true clearTimeout(timer) @@ -245,8 +245,13 @@ export async function startChromeBridge({ directory, resolveBackend }) { 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) + try { + await writeFile(temporary, JSON.stringify({ protocol: 1, pipe, token }), { mode: 0o600 }) + await rename(temporary, discovery) + } catch (error) { + server.close() + throw error + } return async () => { for (const socket of sockets) socket.destroy() server.close() diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc0e7b2..cdcf862 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/package-lock.json b/package-lock.json index c0a8a86..325aa42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",