Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1282de09ba | ||
|
|
0fa42332c1 | ||
|
|
21aa3c2247 | ||
|
|
55a1beaf9d | ||
|
|
1acb5dd90f | ||
|
|
61d765e2df | ||
|
|
b1db2870c1 | ||
|
|
7e4cc0915c | ||
|
|
58dbb4523b | ||
|
|
2ed9a6b2de | ||
|
|
03d3803dfc | ||
|
|
25d8c18c10 | ||
|
|
fe0a45a84f | ||
|
|
67a99be224 | ||
|
|
9240576937 | ||
|
|
233885d3a7 | ||
|
|
9a1d831d4b | ||
|
|
1fe367177c | ||
|
|
40c3c74282 | ||
|
|
ee73fa8cce | ||
|
|
f960716483 | ||
|
|
5f75dcc99a | ||
|
|
ac6687181e | ||
|
|
3f12c627ab | ||
|
|
4115f1b4c0 | ||
|
|
c1929957a2 | ||
|
|
b66daed07a | ||
|
|
9f9f3226a1 | ||
|
|
3332458708 | ||
|
|
ff2eb47302 | ||
|
|
fa63e03eb7 |
@@ -0,0 +1,110 @@
|
|||||||
|
# AITURK IDE ajan CI (kart turkservis-20261007-1421-2).
|
||||||
|
#
|
||||||
|
# Bu depo bir yukari akis catalidir; .github/workflows altindaki GitHub is akislari Gitea'da kosmaz. .gitea/workflows
|
||||||
|
# klasoru var oldugu surece Gitea yalniz buradaki is akislarini okur.
|
||||||
|
#
|
||||||
|
# Ne kosar: korumali talimat dosyasi onay kapisinin testleri (insana sorulamayan kosuda aninda BLOCKED, gec onayin
|
||||||
|
# uygulanmamasi, yolo ile kapinin acilmamasi) ve dosya yazma guvenligi testleri. Kosucu Windows'tur (etiket
|
||||||
|
# windows-latest; 7 Ekim 2026'da cevrimici kosucu hermes-laptop-windows): urunun hedefi Windows x64'tur ve hata
|
||||||
|
# Windows asama kosusunda gorulmustur.
|
||||||
|
#
|
||||||
|
# Ne sayilmaz (durustluk notu): tests/tools/test_file_write_safety.py icindeki 7 test Windows'ta bu degisiklikten
|
||||||
|
# bagimsiz olarak gecemez; yukari akis bunlari POSIX icin yazmistir (/etc, /private/etc, /boot yollari; yonetici
|
||||||
|
# ayricaligi isteyen symlink; POSIX stat alanlari). Asagida adlariyla deselect edilir; degismemis 58dbb4523b
|
||||||
|
# commit'inde de ayni 7 test kaliyordu (7 Ekim 2026 olcumu). Tam yukari akis test takimi burada KOSMAZ.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- aiturk/main
|
||||||
|
- fix/ide-windows-vcruntime
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mobile-link-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
- name: Mobil TLS, profil ve baslatma regresyonlari
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$deps = Join-Path $env:RUNNER_TEMP "mobile-tests-$env:GITHUB_RUN_ID"
|
||||||
|
New-Item -ItemType Directory -Force -Path $deps | Out-Null
|
||||||
|
npm install --prefix $deps --no-save --ignore-scripts --no-audit --no-fund --legacy-peer-deps vitest@4.1.10 ws@8.21.1
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Test bagimliliklari kurulamadi' }
|
||||||
|
New-Item -ItemType Junction -Path node_modules -Target (Join-Path $deps 'node_modules') | Out-Null
|
||||||
|
node node_modules/vitest/vitest.mjs run --root apps/desktop --config vitest.mobile.config.ts
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
|
||||||
|
approval-gate-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- name: Kaynagi al
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
|
||||||
|
- name: Ortami kur
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
"kosucu: $env:COMPUTERNAME / $env:USERNAME"
|
||||||
|
"checkout SHA: $(git rev-parse HEAD) / beklenen: $env:GITHUB_SHA"
|
||||||
|
$py = $null
|
||||||
|
$launcher = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($launcher) { $py = (& $launcher.Source -3 -c 'import sys; print(sys.executable)').Trim() }
|
||||||
|
if (-not $py) {
|
||||||
|
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd -and $cmd.Source -notlike '*\WindowsApps\*') { $py = $cmd.Source }
|
||||||
|
}
|
||||||
|
if (-not $py) { throw 'Python bulunamadi' }
|
||||||
|
"onyukleme python: $py"
|
||||||
|
& $py -m pip install --quiet --disable-pip-version-check --target "$env:RUNNER_TEMP\uvboot" uv
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'uv kurulamadi' }
|
||||||
|
$env:PYTHONPATH = "$env:RUNNER_TEMP\uvboot"
|
||||||
|
& $py -m uv venv --python 3.11 "$env:RUNNER_TEMP\ci-venv"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'venv olusturulamadi' }
|
||||||
|
& $py -m uv pip install --python "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" -e . pytest
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'bagimliliklar kurulamadi' }
|
||||||
|
$env:PYTHONPATH = ''
|
||||||
|
& "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe" --version
|
||||||
|
|
||||||
|
- name: Onay kapisi testleri
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||||
|
$env:PYTHONUTF8 = '1'
|
||||||
|
$venv = "$env:RUNNER_TEMP\ci-venv\Scripts\python.exe"
|
||||||
|
$f = 'tests/tools/test_file_write_safety.py'
|
||||||
|
$sayilmayan = @(
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_etc_hosts_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_hosts_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_etc_ssh_config_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_private_var_blocked",
|
||||||
|
"${f}::TestCheckSensitivePathMacOSBypass::test_boot_still_blocked",
|
||||||
|
"${f}::TestAtomicWrite::test_patch_routes_through_atomic_write",
|
||||||
|
"${f}::TestProtectedInstructionFiles::test_symlink_to_protected_file_is_gated"
|
||||||
|
)
|
||||||
|
$deselect = @()
|
||||||
|
foreach ($t in $sayilmayan) { $deselect += '--deselect'; $deselect += $t }
|
||||||
|
"CI OZETI: Windows'ta sayilmayan (deselect) test sayisi: $($sayilmayan.Count)"
|
||||||
|
$sayilmayan | ForEach-Object { " sayilmadi: $_" }
|
||||||
|
& $venv -m pytest tests/tools/test_protected_instruction_unattended.py $f `
|
||||||
|
tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py `
|
||||||
|
tests/cli/test_single_query_session_finalize.py `
|
||||||
|
tests/hermes_cli/test_plugin_catalog.py tests/hermes_cli/test_aiturk_plugin_catalog.py `
|
||||||
|
tests/hermes_cli/test_plugin_install_ref.py `
|
||||||
|
tests/tui_gateway/test_mobile_subagents.py @deselect -q -p no:cacheprovider -rf
|
||||||
|
$kod = $LASTEXITCODE
|
||||||
|
"pytest cikis kodu: $kod"
|
||||||
|
exit $kod
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# AITURK Chrome beta
|
# AITURK Chrome beta
|
||||||
|
|
||||||
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
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.
|
Requires AITURK IDE 1.0.0-beta.9 or later; see `KURULUM.txt` for Turkish setup.
|
||||||
|
|
||||||
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
||||||
messaging. The per-user .NET Framework host authenticates to Electron's random
|
messaging. The per-user .NET Framework host authenticates to Electron's random
|
||||||
@@ -12,7 +12,8 @@ runtime identity, then submits only the new prompt. No parallel agent backend,
|
|||||||
history reconstruction, model API key in the browser or telemetry is involved.
|
history reconstruction, model API key in the browser or telemetry is involved.
|
||||||
|
|
||||||
Only the active local profile is supported. The list searches titles among
|
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
|
the most recent 200 desktop sessions (two API pages of at most 100, filtered
|
||||||
|
server-side before pagination). The transcript shows the last
|
||||||
40 records, with up to 8,000 characters per visible message; the model's existing
|
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
|
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
|
remain in the IDE. A backend event is stamped once and delivered to every
|
||||||
@@ -40,6 +41,18 @@ npm ci --workspace apps/desktop --include-workspace-root
|
|||||||
node --test apps/chrome/tests/panel.test.mjs
|
node --test apps/chrome/tests/panel.test.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Exercise the production FastAPI session router and real temporary SQLite stores
|
||||||
|
with the JS bridge (no model requests or user data):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AITURK_TEST_PYTHON = 'C:/path/to/python-with-hermes-dependencies.exe'
|
||||||
|
node --test apps/chrome/tests/sessions-api.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
This contract test reproduces the original 422 for an oversized page, then
|
||||||
|
verifies 200 desktop conversations across two valid pages, profile isolation,
|
||||||
|
short/empty lists, archived/empty/cron exclusion and bounded pagination.
|
||||||
|
|
||||||
Run backend fan-out and replay tests with the canonical hermetic runner:
|
Run backend fan-out and replay tests with the canonical hermetic runner:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -78,11 +78,27 @@ test('page context is explicit appended reference data, never a replaced system
|
|||||||
assert.ok(prompt.includes('Ignore prior instructions'))
|
assert.ok(prompt.includes('Ignore prior instructions'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('40 long Unicode or escaped transcripts fit the native byte limit with an in-flight answer', () => {
|
||||||
|
for (const text of ['Türkçe ığüşöç', '你好世界', '\u0000\u0001']) {
|
||||||
|
const rows = Array.from({ length: 40 }, (_, id) => ({ id, role: 'assistant', content: text.repeat(8000) }))
|
||||||
|
const response = { id: 'request', result: { messages: publicMessages(rows), inflight: '\u0000'.repeat(16000), model: 'fixture' } }
|
||||||
|
assert.ok(Buffer.byteLength(JSON.stringify(response)) < MAX_FRAME)
|
||||||
|
assert.equal(response.result.messages.length, 40)
|
||||||
|
assert.ok(response.result.messages.every(row => row.text.length > 0))
|
||||||
|
assert.equal(rows[0].content, text.repeat(8000))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function fixture(t) {
|
async function fixture(t) {
|
||||||
const calls = [], peers = []
|
const calls = [], peers = []
|
||||||
const http = createServer((request, response) => {
|
const http = createServer((request, response) => {
|
||||||
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
if (request.url.startsWith('/api/sessions?')) {
|
||||||
|
const query = new URL(request.url, 'http://localhost').searchParams
|
||||||
|
assert.ok(Number(query.get('limit')) <= 100)
|
||||||
|
assert.equal(query.get('source'), 'desktop')
|
||||||
|
}
|
||||||
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
||||||
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
||||||
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Real session router + disposable SQLite stores for the JS bridge contract.
|
||||||
|
|
||||||
|
Run by sessions-api.test.mjs. Only service dependencies are replaced; query
|
||||||
|
validation, route handling, SQL filters and pagination are production code.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aiturk-chrome-api-") as directory:
|
||||||
|
os.environ["HERMES_HOME"] = directory
|
||||||
|
from fastapi import FastAPI, HTTPException, WebSocket
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import uvicorn
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
from hermes_cli.web_routers import sessions
|
||||||
|
|
||||||
|
paths = {name: Path(directory) / f"{name}.db" for name in ("default", "small", "empty")}
|
||||||
|
for profile, count in (("default", 230), ("small", 3), ("empty", 0)):
|
||||||
|
db = SessionDB(db_path=paths[profile])
|
||||||
|
for index in range(count):
|
||||||
|
sid = f"{profile}-desktop-{index:03d}"
|
||||||
|
db.create_session(sid, source="desktop")
|
||||||
|
db.set_session_title(sid, sid)
|
||||||
|
db.append_message(sid, role="user", content="Fixture context")
|
||||||
|
if count:
|
||||||
|
for index in range(240):
|
||||||
|
sid = f"{profile}-cron-{index:03d}"
|
||||||
|
db.create_session(sid, source="cron")
|
||||||
|
db.set_session_title(sid, sid)
|
||||||
|
db.append_message(sid, role="user", content="Not a desktop conversation")
|
||||||
|
db.create_session(f"{profile}-blank", source="desktop")
|
||||||
|
db.create_session(f"{profile}-archived", source="desktop")
|
||||||
|
db.append_message(f"{profile}-archived", role="user", content="Archived")
|
||||||
|
db._conn.execute("UPDATE sessions SET archived=1 WHERE id=?", (f"{profile}-archived",))
|
||||||
|
db._conn.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def profile_home(profile):
|
||||||
|
if profile not in paths:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return profile, Path(directory)
|
||||||
|
|
||||||
|
sessions._cron_profile_home = profile_home
|
||||||
|
sessions._cron_default_profile = lambda: "default"
|
||||||
|
sessions._maybe_auto_archive_for_profile = lambda profile: None
|
||||||
|
sessions._open_session_db_for_profile = lambda profile, read_only=True: SessionDB(
|
||||||
|
db_path=paths[profile or "default"], read_only=read_only
|
||||||
|
)
|
||||||
|
# The fixture stores no system/model secrets; list projection is unrelated
|
||||||
|
# to this contract and must not import the full web-server startup path.
|
||||||
|
sessions._strip_session_list_rows = lambda rows: None
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(sessions.list_router)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth(request, call_next):
|
||||||
|
if request.headers.get("X-Hermes-Session-Token") != "fixture-only":
|
||||||
|
return JSONResponse({}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@app.websocket("/api/ws")
|
||||||
|
async def ws(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
server = uvicorn.Server(uvicorn.Config(app, log_level="critical"))
|
||||||
|
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
while not server.started:
|
||||||
|
if not thread.is_alive() or time.monotonic() > deadline:
|
||||||
|
raise RuntimeError("Fixture failed to start")
|
||||||
|
time.sleep(0.01)
|
||||||
|
print(f"READY {port}", flush=True)
|
||||||
|
# EOF shuts down cleanly, including Windows venv launcher/worker pairs.
|
||||||
|
sys.stdin.read()
|
||||||
|
server.should_exit = True
|
||||||
|
thread.join(timeout=15)
|
||||||
|
sock.close()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { once } from 'node:events'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { ChromeGatewayClient } from '../../desktop/electron/chrome-bridge.mjs'
|
||||||
|
|
||||||
|
test('real FastAPI/SQLite: valid bounded desktop pages, profile scope and empty states', {
|
||||||
|
skip: !process.env.AITURK_TEST_PYTHON && 'Set AITURK_TEST_PYTHON to run the real API contract',
|
||||||
|
timeout: 90000,
|
||||||
|
}, async t => {
|
||||||
|
// No credentials/config inherited by the disposable Python service.
|
||||||
|
const env = Object.fromEntries(['PATH', 'SYSTEMROOT', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA']
|
||||||
|
.filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||||
|
env.PYTHONUTF8 = '1'
|
||||||
|
const child = spawn(process.env.AITURK_TEST_PYTHON,
|
||||||
|
[fileURLToPath(new URL('./sessions-api-fixture.py', import.meta.url))], { env, windowsHide: true })
|
||||||
|
const exited = once(child, 'exit')
|
||||||
|
let stdout = '', stderr = ''
|
||||||
|
child.stderr.on('data', chunk => { stderr += chunk })
|
||||||
|
t.after(async () => { child.stdin.end(); await exited })
|
||||||
|
const port = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Fixture startup timeout: ${stderr}`)), 45000)
|
||||||
|
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||||
|
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Fixture exited: ${stderr}`)) })
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
stdout += chunk
|
||||||
|
const match = stdout.match(/READY (\d+)/)
|
||||||
|
if (match) { clearTimeout(timer); resolve(Number(match[1])) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
const old = await fetch(`${baseUrl}/api/sessions?limit=200`, { headers: { 'X-Hermes-Session-Token': 'fixture-only' } })
|
||||||
|
assert.equal(old.status, 422, 'production validation reproduces the reported failure')
|
||||||
|
assert.ok((await old.json()).detail.some(error => error.loc.includes('limit')))
|
||||||
|
for (const [profile, expected, requests] of [['default', 200, 2], ['small', 3, 1], ['empty', 0, 1]]) {
|
||||||
|
const client = new ChromeGatewayClient(async () => ({ baseUrl, wsUrl: `${baseUrl.replace('http:', 'ws:')}/api/ws`,
|
||||||
|
token: 'fixture-only', mode: 'local', profile }), () => {})
|
||||||
|
const paths = []
|
||||||
|
const realRest = client.rest.bind(client)
|
||||||
|
client.rest = path => { paths.push(path); return realRest(path) }
|
||||||
|
try {
|
||||||
|
const result = await client.handle({ id: 'contract', action: 'sessions.list' })
|
||||||
|
assert.equal(result.sessions.length, expected)
|
||||||
|
assert.equal(new Set(result.sessions.map(row => row.id)).size, expected)
|
||||||
|
assert.ok(result.sessions.every(row => row.id.startsWith(`${profile}-desktop-`)))
|
||||||
|
assert.equal(result.profile, profile)
|
||||||
|
assert.equal(paths.length, requests)
|
||||||
|
for (const [index, path] of paths.entries()) {
|
||||||
|
const query = new URL(path, baseUrl).searchParams
|
||||||
|
assert.equal(query.get('offset'), String(index * 100))
|
||||||
|
assert.equal(query.get('source'), 'desktop')
|
||||||
|
assert.equal(query.get('profile'), profile)
|
||||||
|
}
|
||||||
|
} finally { client.close() }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"product": "Microsoft Visual C++ 2015-2022 Redistributable (VC143 CRT)",
|
||||||
|
"version": "14.44.35211.0",
|
||||||
|
"architecture": "x64",
|
||||||
|
"publisher": "Microsoft Corporation (Authenticode doğrulandı)",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "msvcp140.dll",
|
||||||
|
"size": 557728,
|
||||||
|
"sha256": "0f885b509a685d2bbfa652fed26b5fb31d88fbdab0a978c641d1c7b8aa460aa9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_1.dll",
|
||||||
|
"size": 35952,
|
||||||
|
"sha256": "bfad5aef4c63a669e3c140655cdfdf395b6c979b400a447bd5dcb65ed8826c3d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_2.dll",
|
||||||
|
"size": 280200,
|
||||||
|
"sha256": "3ea06f0ee098b4823cb79599df3780e7f23cce52c19aac31d2a0d47efe33a5e9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_atomic_wait.dll",
|
||||||
|
"size": 50304,
|
||||||
|
"sha256": "640b2aefced484d0368eea5bdd06addd0658a3a70a49256e560d6923b404a479"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "msvcp140_codecvt_ids.dll",
|
||||||
|
"size": 31872,
|
||||||
|
"sha256": "f2069a52880ec885ee7f0511186100eb7fada0411a2b4948fafea7735b878a18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "concrt140.dll",
|
||||||
|
"size": 324208,
|
||||||
|
"sha256": "2405355f0a58067b258f8df33c327e3a3d716eaac5a3a5aebb757842d85bd376"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vcruntime140.dll",
|
||||||
|
"size": 124544,
|
||||||
|
"sha256": "d5e4d9a3e835fa679450145d6a7d94e36573a509317111904d9b3712c30d9066",
|
||||||
|
"ownedByPython": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vcruntime140_1.dll",
|
||||||
|
"size": 49792,
|
||||||
|
"sha256": "1f2d41c4aa5db0bc33ebf7b66d72943a817d7ce6cbe880502a9403823633093f",
|
||||||
|
"ownedByPython": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import schema from './codex-link-control.schema.json'
|
||||||
|
import type { CodexLinkControlRequest, CodexLinkControlResponse } from './codex-link-control.types'
|
||||||
|
import { canonicalJson, CodexLinkValidationError, parseStrictJson } from './codex-link-json'
|
||||||
|
|
||||||
|
export const CODEX_LINK_REQUEST_BYTES = 64 * 1024
|
||||||
|
export const CODEX_LINK_RESPONSE_BYTES = 1024 * 1024
|
||||||
|
type Rule = Record<string, any>
|
||||||
|
const definitions: Record<string, Rule> = schema.$defs
|
||||||
|
|
||||||
|
function matches(value: any, rule: Rule): boolean {
|
||||||
|
if (rule.$ref) {
|
||||||
|
const target = definitions[rule.$ref.replace('#/$defs/', '')]
|
||||||
|
|
||||||
|
return !!target && matches(value, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.oneOf) {
|
||||||
|
return rule.oneOf.filter((r: Rule) => matches(value, r)).length === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.anyOf) {
|
||||||
|
return rule.anyOf.some((r: Rule) => matches(value, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('const' in rule && value !== rule.const) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.enum && !rule.enum.includes(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (rule.type) {
|
||||||
|
case 'object': {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
keys.length >= (rule.minProperties ?? 0) &&
|
||||||
|
rule.required.every((key: string) => Object.hasOwn(value, key)) &&
|
||||||
|
keys.every(key => Object.hasOwn(rule.properties, key) && matches(value[key], rule.properties[key]))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length >= (rule.minItems ?? 0) &&
|
||||||
|
(!rule.uniqueItems || new Set(value.map(canonicalJson)).size === value.length) &&
|
||||||
|
value.every(item => matches(item, rule.items))
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
[...value].length >= (rule.minLength ?? 0) &&
|
||||||
|
(!rule.pattern || new RegExp(rule.pattern).test(value))
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'integer':
|
||||||
|
return (
|
||||||
|
Number.isSafeInteger(value) &&
|
||||||
|
value >= (rule.minimum ?? -Number.MAX_SAFE_INTEGER) &&
|
||||||
|
value <= (rule.maximum ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return typeof value === 'boolean'
|
||||||
|
|
||||||
|
case 'null':
|
||||||
|
return value === null
|
||||||
|
|
||||||
|
case undefined:
|
||||||
|
return 'const' in rule || 'enum' in rule
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateControlRequest(value: unknown): asserts value is CodexLinkControlRequest {
|
||||||
|
const text = canonicalJson(value)
|
||||||
|
|
||||||
|
if (
|
||||||
|
new TextEncoder().encode(text).byteLength > CODEX_LINK_REQUEST_BYTES ||
|
||||||
|
!matches(value, definitions.control_request)
|
||||||
|
) {
|
||||||
|
throw new CodexLinkValidationError('invalid_control_request')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseControlResponse(
|
||||||
|
raw: Uint8Array | string,
|
||||||
|
request: CodexLinkControlRequest
|
||||||
|
): CodexLinkControlResponse {
|
||||||
|
const value = parseStrictJson(raw, CODEX_LINK_RESPONSE_BYTES) as CodexLinkControlResponse
|
||||||
|
|
||||||
|
if (
|
||||||
|
!matches(value, definitions.control_response) ||
|
||||||
|
value.request_id !== request.request_id ||
|
||||||
|
value.operation_id !== request.operation_id
|
||||||
|
) {
|
||||||
|
throw new CodexLinkValidationError('invalid_control_response')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
settings_get: 'settings_result',
|
||||||
|
settings_update: 'settings_result',
|
||||||
|
status_get: 'status_result',
|
||||||
|
context_views_get: 'context_views_result',
|
||||||
|
catalog_sync: 'control_accepted',
|
||||||
|
context_compact_view: 'control_accepted'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.kind !== 'control_error' && value.kind !== expected[request.kind]) {
|
||||||
|
throw new CodexLinkValidationError('unexpected_control_response')
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Generated by scripts/generate-codex-link-control.py. Do not edit.
|
||||||
|
export const CODEX_LINK_CONTROL_SCHEMA_HASH = "sha256:4c46b591ccd508670835f6dacd7f61c30793e98181d63089512b7d812d1b85b8"
|
||||||
|
export type CodexLinkCatalogSync = { "contract_version": "codex-link/1"; "kind": "catalog_sync"; "request_id": string; "operation_id": string }
|
||||||
|
export type CodexLinkContextCompactView = { "contract_version": "codex-link/1"; "kind": "context_compact_view"; "request_id": string; "operation_id": string; "view_handle": string; "expected_checkpoint_revision": number }
|
||||||
|
export type CodexLinkContextView = { "view_handle": string; "can_compact": boolean; "label": string; "checkpoint_revision": number; "current_model_label": string; "limiting_target_id": (string | null); "target_budgets": (Array<CodexLinkTargetBudget> | null); "active_summary_count": number; "summary_tokens": (number | null); "summary_token_measurement": ({ "target_id": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound" | "provider_estimate" } | null); "last_operation_id": (string | null); "last_operation_state": ("accepted" | "running" | "completed" | "failed" | "indeterminate" | null); "last_operation_error": (CodexLinkError | null); "compact_attempts": Array<{ "attempt_id": string; "member_label": string; "state": "prepared" | "running" | "completed" | "failed" | "indeterminate"; "usage": CodexLinkUsage }> }
|
||||||
|
export type CodexLinkContextViewsGet = { "contract_version": "codex-link/1"; "kind": "context_views_get"; "request_id": string; "operation_id": string }
|
||||||
|
export type CodexLinkContextViewsResult = { "contract_version": "codex-link/1"; "kind": "context_views_result"; "request_id": string; "operation_id": string; "settings_revision": number; "views": Array<CodexLinkContextView> }
|
||||||
|
export type CodexLinkControlAccepted = { "contract_version": "codex-link/1"; "kind": "control_accepted"; "request_id": string; "operation_id": string; "settings_revision": number; "operation_state": "accepted" | "running" | "completed" | "failed" | "indeterminate"; "error": (CodexLinkError | null) }
|
||||||
|
export type CodexLinkControlError = { "contract_version": "codex-link/1"; "kind": "control_error"; "request_id": string; "operation_id": string; "settings_revision": number; "error": CodexLinkError }
|
||||||
|
export type CodexLinkControlRequest = (CodexLinkSettingsGet | CodexLinkSettingsUpdate | CodexLinkStatusGet | CodexLinkCatalogSync | CodexLinkContextViewsGet | CodexLinkContextCompactView)
|
||||||
|
export type CodexLinkControlResponse = (CodexLinkSettingsResult | CodexLinkStatusResult | CodexLinkContextViewsResult | CodexLinkControlAccepted | CodexLinkControlError)
|
||||||
|
export type CodexLinkError = { "code": string; "message": string; "recovery_action": "retry_control_read" | "reconcile_only" | "repair_required" | "select_route" | "reconfigure" | "none" }
|
||||||
|
export type CodexLinkOutputCapEnforcement = ({ "method": "request_field"; "field": "max_output_tokens" | "max_tokens" | "max_completion_tokens" | "num_predict"; "value": number; "proof_revision": string; "verified": true } | { "method": "provider_fixed_maximum"; "value": number; "proof_revision": string; "verified": true })
|
||||||
|
export type CodexLinkSettings = { "settings_revision": number; "enabled": boolean; "compression_enabled": boolean; "threshold_tokens": number; "source_binding_handle": (string | null); "compact_route_id": (string | null); "legacy_intent_imported": boolean; "ownership_manifest_revision": number; "activation_state": "disabled" | "pending_runtime_acceptance" | "ready" | "restart_required" | "conflict" | "recovery_pending" }
|
||||||
|
export type CodexLinkSettingsGet = { "contract_version": "codex-link/1"; "kind": "settings_get"; "request_id": string; "operation_id": string }
|
||||||
|
export type CodexLinkSettingsResult = { "contract_version": "codex-link/1"; "kind": "settings_result"; "request_id": string; "operation_id": string; "settings": CodexLinkSettings; "available_sources": Array<{ "source_binding_handle": string; "label": string; "authorized": boolean }> }
|
||||||
|
export type CodexLinkSettingsUpdate = { "contract_version": "codex-link/1"; "kind": "settings_update"; "request_id": string; "operation_id": string; "expected_settings_revision": number; "patch": { "enabled"?: boolean; "compression_enabled"?: boolean; "threshold_tokens"?: number; "source_binding_handle"?: string } }
|
||||||
|
export type CodexLinkStatusGet = { "contract_version": "codex-link/1"; "kind": "status_get"; "request_id": string; "operation_id": string }
|
||||||
|
export type CodexLinkStatusResult = { "contract_version": "codex-link/1"; "kind": "status_result"; "request_id": string; "operation_id": string; "status": CodexLinkStatusSnapshot }
|
||||||
|
export type CodexLinkStatusSnapshot = { "settings_revision": number; "runtime_mode": "native" | "managed" | "unknown"; "native_available": boolean; "external_admission": "open" | "closed" | "draining" | "recovery_required"; "needs_restart": boolean; "catalog_state": "unknown" | "fresh" | "stale" | "unauthorized" | "empty"; "active_turns": number; "uncertain_operations": number; "last_operation_id": (string | null); "error": (CodexLinkError | null) }
|
||||||
|
export type CodexLinkTargetBudget = { "target_id": string; "canonical_leaf_id": string; "limits_source": "provider_contract" | "provider_api" | "verified_adapter" | "verified_measurement"; "limits_revision": string; "tokenizer_id": string; "tokenizer_revision": string; "count_method": "exact_tokenizer" | "verified_upper_bound" | "provider_estimate"; "serialization_revision": string; "input_tokens": number; "context_window_tokens": number; "output_cap_tokens": number; "safety_margin_tokens": number; "max_input_tokens": (number | null); "max_output_tokens": number; "request_bytes": number; "max_request_bytes": number; "output_cap_enforcement": CodexLinkOutputCapEnforcement }
|
||||||
|
export type CodexLinkUsage = { "input_tokens": (number | null); "output_tokens": (number | null); "measurement": "measured" | "estimated" | "unknown"; "cost_microunits": (number | null); "currency": ("USD" | null); "cost_source": (string | null) }
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Strict P1 JSON domain/parser, shared algorithm with router codexLinkValidation.
|
||||||
|
// Browser-safe; never apply to the opaque native/provider JSON body.
|
||||||
|
type Data = Record<string, any>
|
||||||
|
|
||||||
|
export class CodexLinkValidationError extends Error {
|
||||||
|
readonly code: string
|
||||||
|
constructor(code: string) {
|
||||||
|
super(code)
|
||||||
|
this.code = code
|
||||||
|
this.name = 'CodexLinkValidationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireValue(condition: unknown, code: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new CodexLinkValidationError(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): Data {
|
||||||
|
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), 'schema_rejected')
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
requireValue(prototype === Object.prototype || prototype === null, 'schema_rejected')
|
||||||
|
|
||||||
|
return value as Data
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: unknown, positive = false): asserts value is number {
|
||||||
|
requireValue(Number.isSafeInteger(value) && (value as number) >= (positive ? 1 : 0), 'schema_rejected')
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(value: unknown): asserts value is string {
|
||||||
|
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value), 'schema_rejected')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value: Data, keys: string[]) {
|
||||||
|
requireValue(
|
||||||
|
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)),
|
||||||
|
'schema_rejected'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unicode(value: string) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i)
|
||||||
|
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
const low = value.charCodeAt(++i)
|
||||||
|
requireValue(low >= 0xdc00 && low <= 0xdfff, 'invalid_unicode')
|
||||||
|
} else {
|
||||||
|
requireValue(code < 0xdc00 || code > 0xdfff, 'invalid_unicode')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P1 canonical domain: ASCII object keys, safe integers, exact Unicode text. */
|
||||||
|
export function canonicalJson(value: unknown): string {
|
||||||
|
const ancestors = new Set<object>()
|
||||||
|
|
||||||
|
function encode(item: unknown, depth: number): string {
|
||||||
|
requireValue(depth <= 256, 'invalid_json')
|
||||||
|
|
||||||
|
if (item === null || typeof item === 'boolean') {
|
||||||
|
return JSON.stringify(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
unicode(item)
|
||||||
|
|
||||||
|
return JSON.stringify(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === 'number') {
|
||||||
|
requireValue(Number.isInteger(item), 'floats_or_non_json_type')
|
||||||
|
requireValue(Number.isSafeInteger(item), 'unsafe_integer')
|
||||||
|
|
||||||
|
return JSON.stringify(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
requireValue(typeof item === 'object' && item !== null, 'floats_or_non_json_type')
|
||||||
|
requireValue(!ancestors.has(item), 'invalid_json')
|
||||||
|
ancestors.add(item)
|
||||||
|
let result: string
|
||||||
|
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
result = '[' + item.map(child => encode(child, depth + 1)).join(',') + ']'
|
||||||
|
} else {
|
||||||
|
const data = object(item)
|
||||||
|
const keys = Object.keys(data).sort()
|
||||||
|
requireValue(
|
||||||
|
keys.every(key => [...key].every(char => char.charCodeAt(0) <= 127)),
|
||||||
|
'non_ascii_key'
|
||||||
|
)
|
||||||
|
result = '{' + keys.map(key => JSON.stringify(key) + ':' + encode(data[key], depth + 1)).join(',') + '}'
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestors.delete(item)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return encode(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bounded parser that detects duplicates before JSON.parse can erase them. */
|
||||||
|
export function parseStrictJson(raw: string | Uint8Array, maxBytes: number): unknown {
|
||||||
|
integer(maxBytes, true)
|
||||||
|
const rawBytes = typeof raw === 'string' ? new TextEncoder().encode(raw).byteLength : raw.byteLength
|
||||||
|
requireValue(rawBytes <= maxBytes, 'transport_bytes_exceeded')
|
||||||
|
let text: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw)
|
||||||
|
} catch {
|
||||||
|
throw new CodexLinkValidationError('invalid_json')
|
||||||
|
}
|
||||||
|
|
||||||
|
unicode(text)
|
||||||
|
let position = 0
|
||||||
|
|
||||||
|
function whitespace() {
|
||||||
|
while (/[ \t\r\n]/.test(text[position] ?? '') && position < text.length) {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoted(): string {
|
||||||
|
const start = position++
|
||||||
|
|
||||||
|
while (position < text.length) {
|
||||||
|
const next = text[position++]
|
||||||
|
|
||||||
|
if (next === '\\') {
|
||||||
|
position++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '"') {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(text.slice(start, position))
|
||||||
|
unicode(value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodexLinkValidationError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CodexLinkValidationError('invalid_json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CodexLinkValidationError('invalid_json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(depth: number): unknown {
|
||||||
|
requireValue(depth <= 256, 'invalid_json')
|
||||||
|
whitespace()
|
||||||
|
const token = text[position]
|
||||||
|
|
||||||
|
if (token === '"') {
|
||||||
|
return quoted()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === '{') {
|
||||||
|
position++
|
||||||
|
whitespace()
|
||||||
|
const value: Data = Object.create(null)
|
||||||
|
|
||||||
|
if (text[position] === '}') {
|
||||||
|
position++
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
while (position < text.length) {
|
||||||
|
requireValue(text[position] === '"', 'invalid_json')
|
||||||
|
const key = quoted()
|
||||||
|
requireValue(
|
||||||
|
[...key].every(char => char.charCodeAt(0) <= 127),
|
||||||
|
'non_ascii_key'
|
||||||
|
)
|
||||||
|
requireValue(!Object.hasOwn(value, key), 'duplicate_json_key')
|
||||||
|
whitespace()
|
||||||
|
requireValue(text[position++] === ':', 'invalid_json')
|
||||||
|
value[key] = read(depth + 1)
|
||||||
|
whitespace()
|
||||||
|
const separator = text[position++]
|
||||||
|
|
||||||
|
if (separator === '}') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
requireValue(separator === ',', 'invalid_json')
|
||||||
|
whitespace()
|
||||||
|
}
|
||||||
|
} else if (token === '[') {
|
||||||
|
position++
|
||||||
|
whitespace()
|
||||||
|
const value: unknown[] = []
|
||||||
|
|
||||||
|
if (text[position] === ']') {
|
||||||
|
position++
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
while (position < text.length) {
|
||||||
|
value.push(read(depth + 1))
|
||||||
|
whitespace()
|
||||||
|
const separator = text[position++]
|
||||||
|
|
||||||
|
if (separator === ']') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
requireValue(separator === ',', 'invalid_json')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [word, value] of [
|
||||||
|
['true', true],
|
||||||
|
['false', false],
|
||||||
|
['null', null]
|
||||||
|
] as const) {
|
||||||
|
if (text.startsWith(word, position)) {
|
||||||
|
position += word.length
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(?:NaN|[-+]?Infinity)/.test(text.slice(position))) {
|
||||||
|
throw new CodexLinkValidationError('floats_or_non_json_type')
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^-?(?:0|[1-9]\d*)/.exec(text.slice(position))
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
position += match[0].length
|
||||||
|
requireValue(!/[.eE]/.test(text[position] ?? ''), 'floats_or_non_json_type')
|
||||||
|
const number = Number(match[0])
|
||||||
|
requireValue(Number.isSafeInteger(number), 'unsafe_integer')
|
||||||
|
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CodexLinkValidationError('invalid_json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = read(0)
|
||||||
|
whitespace()
|
||||||
|
requireValue(position === text.length, 'invalid_json')
|
||||||
|
canonicalJson(value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Mobile connection repair — beta.19
|
||||||
|
|
||||||
|
The paired phone previously resolved the desktop's active profile. If that
|
||||||
|
profile's startup promise was waiting for first-run setup, every phone API
|
||||||
|
request waited too, even while the default backend was already serving.
|
||||||
|
|
||||||
|
REST and WebSocket requests now resolve the phone's explicit `profile` query
|
||||||
|
(default when absent). Aggregate `profile=all` reaches the default process
|
||||||
|
without rewriting the downstream query. Invalid profile identifiers, remote
|
||||||
|
backends and a missing local runtime are refused; phone requests do not install
|
||||||
|
software or silently fall back to a different profile. Resolution has an eight
|
||||||
|
second deadline; this is not an agent, tool or scheduled-job execution deadline.
|
||||||
|
|
||||||
|
Shared-runtime completion wakes only a waiting setup for the same runtime root.
|
||||||
|
Primary startup then resolves its own profile again. This does not grant
|
||||||
|
installation consent or substitute a sibling profile's backend connection.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- 94 mobile transport, certificate, relay, profile and startup regression tests.
|
||||||
|
- Electron TypeScript type check.
|
||||||
|
- Opt-in `mobile-live.test.ts`, with `AITURK_TEST_BACKEND_PORT`: real local
|
||||||
|
backend behind the production TLS proxy, pinned certificate, screen reads,
|
||||||
|
cron create/update/pause/resume/delete and chat WebSocket ping. Test-created
|
||||||
|
job is deleted; existing jobs are untouched. This test does not trigger an
|
||||||
|
agent job or claim physical-phone/cellular acceptance.
|
||||||
|
- Public relay hello was separately exercised using Android's production
|
||||||
|
OkHttp/Conscrypt stack. A successful hello alone does not prove API readiness.
|
||||||
|
|
||||||
|
Publication, installed beta.19 and physical-phone acceptance are separate gates.
|
||||||
|
Existing phone security exclusions are unchanged. Unsupported methods and
|
||||||
|
features are not claimed to be implemented by this connection repair.
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
/**
|
||||||
|
* Tests for electron/aiturk-login.ts — the one-click TurkServis sign-in.
|
||||||
|
* Everything is injected (fake fetch, fake backend, fake CLI, fake clock), so
|
||||||
|
* no network, browser or real CLI is touched. The fixture "keys" below are
|
||||||
|
* obviously fake placeholders in the right shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AITURK_CLI_KEY_HOLD_MS,
|
||||||
|
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||||
|
type AiturkCliExit,
|
||||||
|
AiturkLoginError,
|
||||||
|
createAiturkCliRunner,
|
||||||
|
createAiturkLogin,
|
||||||
|
resolveAiturkCliPath
|
||||||
|
} from './aiturk-login'
|
||||||
|
|
||||||
|
const DEVICE_CODE = `tsdl_${'d'.repeat(43)}`
|
||||||
|
const IDE_KEY = 'tsk_FAKE-ide-key-for-tests'
|
||||||
|
const CLI_KEY = 'tsk_FAKE-cli-key-for-tests'
|
||||||
|
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||||
|
|
||||||
|
const startBody = (over: Record<string, unknown> = {}) => ({
|
||||||
|
loginId: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||||
|
deviceCode: DEVICE_CODE,
|
||||||
|
userCode: 'ABCD2345',
|
||||||
|
expiresIn: 600,
|
||||||
|
interval: 3,
|
||||||
|
verificationUri: URI,
|
||||||
|
...over
|
||||||
|
})
|
||||||
|
|
||||||
|
const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'acct_test' }
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
options: {
|
||||||
|
start?: { status: number; body?: unknown }
|
||||||
|
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||||
|
startErrors?: Error[]
|
||||||
|
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||||
|
cliExits?: AiturkCliExit[]
|
||||||
|
validate?: unknown
|
||||||
|
save?: unknown
|
||||||
|
openExternal?: () => boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const calls: { url: string; init: any }[] = []
|
||||||
|
const polls = [...(options.polls ?? [])]
|
||||||
|
const startErrors = [...(options.startErrors ?? [])]
|
||||||
|
const cliExits = [...(options.cliExits ?? [0])]
|
||||||
|
const clock = { now: 1_000_000 }
|
||||||
|
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||||
|
|
||||||
|
const fetch = vi.fn(async (url: string, init: any) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
|
||||||
|
if (url.endsWith('/start')) {
|
||||||
|
const failure = startErrors.shift()
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = options.start ?? { status: 201, body: startBody() }
|
||||||
|
|
||||||
|
return { status: r.status, json: async () => r.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith('/poll')) {
|
||||||
|
const r = polls.shift() ?? { status: 200, body: { state: 'pending' } }
|
||||||
|
|
||||||
|
if (r instanceof Error) {
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: r.status, json: async () => r.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, json: async () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const backendRequest = vi.fn(async (request: any) => {
|
||||||
|
if (request.path.endsWith('/validate')) {
|
||||||
|
return options.validate ?? { ok: true, reachable: true, models: ['account/model-a', 'account/model-b'], message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.save ?? { ok: true, id: 'turkservis', endpoints: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const runCli = vi.fn(async (_key: string, _options: { keepOtherAccount: boolean }) =>
|
||||||
|
cliExits.length ? (cliExits.shift() as AiturkCliExit) : 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const openExternal = vi.fn(options.openExternal ?? (() => true))
|
||||||
|
|
||||||
|
const login = createAiturkLogin({
|
||||||
|
fetch,
|
||||||
|
openExternal,
|
||||||
|
hostname: () => 'TEST-PC',
|
||||||
|
backendRequest,
|
||||||
|
runCli,
|
||||||
|
now: () => clock.now,
|
||||||
|
setTimer: (fn, ms) => {
|
||||||
|
const timer = { fn, ms, cleared: false }
|
||||||
|
timers.push(timer)
|
||||||
|
|
||||||
|
return timer
|
||||||
|
},
|
||||||
|
clearTimer: handle => {
|
||||||
|
;(handle as { cleared: boolean }).cleared = true
|
||||||
|
},
|
||||||
|
timeoutSignal: () => undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return { login, fetch, calls, backendRequest, runCli, openExternal, clock, timers }
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaks = (value: unknown) => /tsk_|tsdl_/.test(typeof value === 'string' ? value : JSON.stringify(value) ?? '')
|
||||||
|
|
||||||
|
describe('aiturk login: start', () => {
|
||||||
|
it('asks the server for a code, opens the approval page and returns no secret', async () => {
|
||||||
|
const h = harness()
|
||||||
|
const started = await h.login.start({ profile: 'work' })
|
||||||
|
|
||||||
|
expect(started).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||||
|
expect(leaks(started)).toBe(false)
|
||||||
|
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||||
|
|
||||||
|
const { url, init } = h.calls[0]
|
||||||
|
expect(url).toBe('https://turkservis.online/v1/aiturk/device-auth/start')
|
||||||
|
expect(init.method).toBe('POST')
|
||||||
|
expect(init.redirect).toBe('error')
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ client: 'ide', device_name: 'TEST-PC', also_cli: true })
|
||||||
|
expect(init.headers.Authorization).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://turkservis.online.evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||||
|
'http://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||||
|
'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e?next=//evil',
|
||||||
|
'https://turkservis.online/account/aiturk/device-login/../../x',
|
||||||
|
'https://evil.example/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||||
|
''
|
||||||
|
])('refuses a verification URL that is not exactly the approval page: %s', async verificationUri => {
|
||||||
|
const h = harness({ start: { status: 201, body: startBody({ verificationUri }) } })
|
||||||
|
|
||||||
|
const error = await h.login.start().catch(e => e)
|
||||||
|
expect(error).toBeInstanceOf(AiturkLoginError)
|
||||||
|
expect(error.message).toBe('aiturk-login:invalid-response')
|
||||||
|
expect(leaks(error.message)).toBe(false)
|
||||||
|
expect(h.openExternal).not.toHaveBeenCalled()
|
||||||
|
// The issued code is revoked instead of being left approvable.
|
||||||
|
expect(h.calls.at(-1)?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 429 to rate-limited and other failures to unavailable', async () => {
|
||||||
|
await expect(harness({ start: { status: 429 } }).login.start()).rejects.toThrow('aiturk-login:rate-limited')
|
||||||
|
await expect(harness({ start: { status: 422 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||||
|
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||||
|
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||||
|
const pending = h.login.start()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||||
|
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||||
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(h.openExternal).not.toHaveBeenCalled()
|
||||||
|
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||||
|
|
||||||
|
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||||
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||||
|
// One code only: the approval page is opened once.
|
||||||
|
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||||
|
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||||
|
const failing = twice.login.start().catch(e => e)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||||
|
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||||
|
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||||
|
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||||
|
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||||
|
|
||||||
|
for (const status of [500, 503, 429, 422]) {
|
||||||
|
const answered = harness({ start: { status } })
|
||||||
|
|
||||||
|
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||||
|
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(answered.timers).toHaveLength(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||||
|
|
||||||
|
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||||
|
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(timedOut.timers).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed codes', async () => {
|
||||||
|
await expect(
|
||||||
|
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||||
|
).rejects.toThrow('aiturk-login:invalid-response')
|
||||||
|
await expect(
|
||||||
|
harness({ start: { status: 201, body: startBody({ deviceCode: 'nope' }) } }).login.start()
|
||||||
|
).rejects.toThrow('aiturk-login:invalid-response')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a single login: a second start revokes the first code', async () => {
|
||||||
|
const h = harness()
|
||||||
|
await h.login.start()
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const urls = h.calls.map(c => c.url.split('/').pop())
|
||||||
|
expect(urls).toEqual(['start', 'cancel', 'start'])
|
||||||
|
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels the code when the browser cannot be opened', async () => {
|
||||||
|
const h = harness({ openExternal: () => false })
|
||||||
|
|
||||||
|
await expect(h.login.start()).rejects.toThrow('aiturk-login:browser-failed')
|
||||||
|
expect(h.calls.at(-1)?.url).toMatch(/\/cancel$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aiturk login: poll', () => {
|
||||||
|
it('reports pending while the user has not approved', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: { state: 'pending' } }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||||
|
expect(h.calls[1].url).toBe('https://turkservis.online/v1/aiturk/device-auth/poll')
|
||||||
|
expect(h.calls[1].init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||||
|
expect(h.calls[1].init.redirect).toBe('error')
|
||||||
|
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||||
|
expect(h.runCli).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps waiting through a transient network error', async () => {
|
||||||
|
const h = harness({ polls: [new Error(`boom ${DEVICE_CODE}`), { status: 503 }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'pending' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports expired on 401 and stops asking the server', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 401, body: { detail: 'expired' } }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||||
|
const count = h.fetch.mock.calls.length
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||||
|
expect(h.fetch.mock.calls.length).toBe(count)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expires by the clock without a request', async () => {
|
||||||
|
const h = harness()
|
||||||
|
await h.login.start()
|
||||||
|
h.clock.now += 600_000
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||||
|
expect(h.calls.filter(c => c.url.endsWith('/poll'))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on approval saves the provider and pipes ONLY the CLI key to the CLI', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
await h.login.start({ profile: 'work', connectionId: 'conn-1' })
|
||||||
|
|
||||||
|
const result = await h.login.poll()
|
||||||
|
expect(result).toEqual({ state: 'connected', cli: 'ok' })
|
||||||
|
expect(leaks(result)).toBe(false)
|
||||||
|
|
||||||
|
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||||
|
expect(h.backendRequest.mock.calls[0][0]).toEqual({
|
||||||
|
connectionId: 'conn-1',
|
||||||
|
path: '/api/providers/custom-endpoints/validate',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
id: 'turkservis',
|
||||||
|
name: 'TurkServis · AITURK',
|
||||||
|
base_url: 'https://ai.turkservis.online/v1',
|
||||||
|
api_key: IDE_KEY,
|
||||||
|
model: '',
|
||||||
|
discover_models: true,
|
||||||
|
make_default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(h.backendRequest.mock.calls[1][0]).toEqual({
|
||||||
|
profile: 'work',
|
||||||
|
connectionId: 'conn-1',
|
||||||
|
path: '/api/providers/custom-endpoints',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
id: 'turkservis',
|
||||||
|
name: 'TurkServis · AITURK',
|
||||||
|
base_url: 'https://ai.turkservis.online/v1',
|
||||||
|
api_key: IDE_KEY,
|
||||||
|
model: 'account/model-a',
|
||||||
|
models: ['account/model-a', 'account/model-b'],
|
||||||
|
discover_models: true,
|
||||||
|
make_default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(h.runCli).toHaveBeenCalledExactlyOnceWith(CLI_KEY, { keepOtherAccount: true })
|
||||||
|
// No key ever rides a URL.
|
||||||
|
expect(h.calls.some(c => leaks(c.url))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask for the one-time keys again on later polls', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const first = await h.login.poll()
|
||||||
|
const fetches = h.fetch.mock.calls.length
|
||||||
|
const second = await h.login.poll()
|
||||||
|
|
||||||
|
expect(second).toEqual(first)
|
||||||
|
expect(h.fetch.mock.calls.length).toBe(fetches)
|
||||||
|
expect(h.backendRequest).toHaveBeenCalledTimes(2)
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares one completion between overlapping polls', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([h.login.poll(), h.login.poll()])
|
||||||
|
expect(a).toEqual({ state: 'connected', cli: 'ok' })
|
||||||
|
expect(b.state === 'connected' || b.state === 'pending').toBe(true)
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 'ok'],
|
||||||
|
[2, 'outdated'],
|
||||||
|
[3, 'other-account'],
|
||||||
|
[4, 'unknown-account'],
|
||||||
|
[1, 'failed'],
|
||||||
|
[null, 'failed'],
|
||||||
|
['missing', 'missing']
|
||||||
|
] as const)('maps CLI exit %s to cli=%s', async (exit, cli) => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [exit] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a throwing CLI runner as failed without leaking', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
h.runCli.mockRejectedValueOnce(new Error(`spawn failed ${CLI_KEY}`))
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const result = await h.login.poll()
|
||||||
|
expect(result).toEqual({ state: 'connected', cli: 'failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not save or touch the CLI when the key is rejected by the backend', async () => {
|
||||||
|
const h = harness({
|
||||||
|
polls: [{ status: 200, body: approved }],
|
||||||
|
validate: { ok: false, reachable: true, models: [], message: `bad key ${IDE_KEY}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const result = await h.login.poll()
|
||||||
|
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||||
|
expect(leaks(result)).toBe(false)
|
||||||
|
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||||
|
expect(h.runCli).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not invent a model when the catalog is empty', async () => {
|
||||||
|
const h = harness({
|
||||||
|
polls: [{ status: 200, body: approved }],
|
||||||
|
validate: { ok: true, reachable: true, models: [], message: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'error', error: 'no-models' })
|
||||||
|
expect(h.backendRequest).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never surfaces backend error text (which could echo the key)', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
h.backendRequest.mockRejectedValueOnce(new Error(`500: echoed ${IDE_KEY}`))
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
const result = await h.login.poll()
|
||||||
|
expect(result).toEqual({ state: 'error', error: 'provider-failed' })
|
||||||
|
expect(leaks(result)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an approval whose key is malformed', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: { state: 'approved', key: 'not a key', cliKey: CLI_KEY } }] })
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'error', error: 'invalid-response' })
|
||||||
|
expect(h.backendRequest).not.toHaveBeenCalled()
|
||||||
|
expect(h.runCli).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aiturk login: other CLI account', () => {
|
||||||
|
async function otherAccount(overwriteExit: AiturkCliExit = 0) {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }], cliExits: [3, overwriteExit] })
|
||||||
|
await h.login.start()
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'other-account' })
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
it('overwrites only after the user confirms, without the keep flag', async () => {
|
||||||
|
const h = await otherAccount()
|
||||||
|
|
||||||
|
const result = await h.login.overwriteCli()
|
||||||
|
expect(result).toEqual({ cli: 'ok' })
|
||||||
|
expect(leaks(result)).toBe(false)
|
||||||
|
expect(h.runCli).toHaveBeenLastCalledWith(CLI_KEY, { keepOtherAccount: false })
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'connected', cli: 'ok' })
|
||||||
|
|
||||||
|
// Single use: the held key is gone.
|
||||||
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an overwrite that still fails', async () => {
|
||||||
|
const h = await otherAccount(4)
|
||||||
|
|
||||||
|
expect(await h.login.overwriteCli()).toEqual({ cli: 'unknown-account' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the held CLI key after five minutes', async () => {
|
||||||
|
const h = await otherAccount()
|
||||||
|
const hold = h.timers.find(t => t.ms === AITURK_CLI_KEY_HOLD_MS)
|
||||||
|
expect(hold).toBeDefined()
|
||||||
|
|
||||||
|
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||||
|
hold?.fn()
|
||||||
|
|
||||||
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a stale held key even if the timer never fired', async () => {
|
||||||
|
const h = await otherAccount()
|
||||||
|
h.clock.now += AITURK_CLI_KEY_HOLD_MS
|
||||||
|
|
||||||
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the held CLI key on cancel', async () => {
|
||||||
|
const h = await otherAccount()
|
||||||
|
await h.login.cancel()
|
||||||
|
|
||||||
|
const error = await h.login.overwriteCli().catch(e => e)
|
||||||
|
expect(error.message).toBe('aiturk-login:nothing-to-overwrite')
|
||||||
|
expect(leaks(error.message)).toBe(false)
|
||||||
|
expect(h.runCli).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nothing to overwrite when the CLI simply succeeded', async () => {
|
||||||
|
const h = harness({ polls: [{ status: 200, body: approved }] })
|
||||||
|
await h.login.start()
|
||||||
|
await h.login.poll()
|
||||||
|
|
||||||
|
await expect(h.login.overwriteCli()).rejects.toThrow('aiturk-login:nothing-to-overwrite')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aiturk login: cancel', () => {
|
||||||
|
it('revokes the code on the server and clears the session', async () => {
|
||||||
|
const h = harness()
|
||||||
|
await h.login.start()
|
||||||
|
|
||||||
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||||
|
const last = h.calls.at(-1)
|
||||||
|
expect(last?.url).toBe('https://turkservis.online/v1/aiturk/device-auth/cancel')
|
||||||
|
expect(last?.init.headers.Authorization).toBe(`Bearer ${DEVICE_CODE}`)
|
||||||
|
|
||||||
|
const count = h.fetch.mock.calls.length
|
||||||
|
expect(await h.login.poll()).toEqual({ state: 'expired' })
|
||||||
|
expect(h.fetch.mock.calls.length).toBe(count)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op without a login and survives a failing server', async () => {
|
||||||
|
const h = harness()
|
||||||
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||||
|
expect(h.fetch).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await h.login.start()
|
||||||
|
h.fetch.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
expect(await h.login.cancel()).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aiturk CLI runner', () => {
|
||||||
|
const CLI_PATH = 'C:\\Users\\Test User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd'
|
||||||
|
const env = { LOCALAPPDATA: 'C:\\Users\\Test User\\AppData\\Local', SystemRoot: 'C:\\Windows' }
|
||||||
|
|
||||||
|
function fakeSpawn(behaviour: (child: any) => void = child => child.emit('close', 0)) {
|
||||||
|
const state: any = { calls: [], stdin: [] as string[], killed: false }
|
||||||
|
|
||||||
|
const spawn = vi.fn((command: string, args: string[], options: any) => {
|
||||||
|
const child: any = new EventEmitter()
|
||||||
|
child.stdin = new EventEmitter()
|
||||||
|
|
||||||
|
child.stdin.end = (chunk: string) => {
|
||||||
|
state.stdin.push(chunk)
|
||||||
|
queueMicrotask(() => behaviour(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill = () => {
|
||||||
|
state.killed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
state.calls.push({ command, args, options })
|
||||||
|
|
||||||
|
return child
|
||||||
|
})
|
||||||
|
|
||||||
|
return { spawn, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('finds the CLI only at the fixed install path', () => {
|
||||||
|
expect(resolveAiturkCliPath({ env, existsSync: p => p === CLI_PATH, platform: 'win32' })).toBe(CLI_PATH)
|
||||||
|
expect(resolveAiturkCliPath({ env, existsSync: () => false, platform: 'win32' })).toBeNull()
|
||||||
|
expect(resolveAiturkCliPath({ env: {}, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||||
|
expect(resolveAiturkCliPath({ env: { LOCALAPPDATA: 'relative' }, existsSync: () => true, platform: 'win32' })).toBeNull()
|
||||||
|
expect(resolveAiturkCliPath({ env, existsSync: () => true, platform: 'linux' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports missing without spawning anything', async () => {
|
||||||
|
const { spawn } = fakeSpawn()
|
||||||
|
const run = createAiturkCliRunner({ spawn, existsSync: () => false, env, platform: 'win32' })
|
||||||
|
|
||||||
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe('missing')
|
||||||
|
expect(spawn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the key on stdin only, through an absolute cmd.exe, without a shell', async () => {
|
||||||
|
const { spawn, state } = fakeSpawn()
|
||||||
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||||
|
|
||||||
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(0)
|
||||||
|
|
||||||
|
const { command, args, options } = state.calls[0]
|
||||||
|
expect(command).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||||
|
expect(args).toEqual(['/d', '/s', '/c', `""${CLI_PATH}" login --key-stdin --keep-other-account"`])
|
||||||
|
expect(options).toEqual({
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: true,
|
||||||
|
stdio: ['pipe', 'ignore', 'ignore']
|
||||||
|
})
|
||||||
|
expect(leaks([command, args, options])).toBe(false)
|
||||||
|
expect(state.stdin).toEqual([`${CLI_KEY}\n`])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the keep flag for a confirmed overwrite', async () => {
|
||||||
|
const { spawn, state } = fakeSpawn()
|
||||||
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||||
|
|
||||||
|
await run(CLI_KEY, { keepOtherAccount: false })
|
||||||
|
expect(state.calls[0].args[3]).toBe(`""${CLI_PATH}" login --key-stdin"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([0, 1, 2, 3, 4])('returns exit code %s', async code => {
|
||||||
|
const { spawn } = fakeSpawn(child => child.emit('close', code))
|
||||||
|
const run = createAiturkCliRunner({ spawn, existsSync: () => true, env, platform: 'win32' })
|
||||||
|
|
||||||
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBe(code)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null on spawn error, thrown spawn and stdin EPIPE', async () => {
|
||||||
|
const errored = fakeSpawn(child => child.emit('error', new Error('ENOENT')))
|
||||||
|
expect(
|
||||||
|
await createAiturkCliRunner({ spawn: errored.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||||
|
keepOtherAccount: true
|
||||||
|
})
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
const thrower = vi.fn(() => {
|
||||||
|
throw new Error('spawn EINVAL')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await createAiturkCliRunner({ spawn: thrower, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||||
|
keepOtherAccount: true
|
||||||
|
})
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
const epipe = fakeSpawn(child => {
|
||||||
|
child.stdin.emit('error', new Error('EPIPE'))
|
||||||
|
child.emit('close', 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await createAiturkCliRunner({ spawn: epipe.spawn, existsSync: () => true, env, platform: 'win32' })(CLI_KEY, {
|
||||||
|
keepOtherAccount: true
|
||||||
|
})
|
||||||
|
).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kills a CLI that hangs past the timeout', async () => {
|
||||||
|
const { spawn, state } = fakeSpawn(() => {})
|
||||||
|
|
||||||
|
let fire = () => {}
|
||||||
|
|
||||||
|
const run = createAiturkCliRunner({
|
||||||
|
spawn,
|
||||||
|
existsSync: () => true,
|
||||||
|
env,
|
||||||
|
platform: 'win32',
|
||||||
|
setTimer: (fn, ms) => {
|
||||||
|
expect(ms).toBe(60_000)
|
||||||
|
fire = fn
|
||||||
|
|
||||||
|
return 1
|
||||||
|
},
|
||||||
|
clearTimer: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = run(CLI_KEY, { keepOtherAccount: true })
|
||||||
|
fire()
|
||||||
|
|
||||||
|
expect(await pending).toBeNull()
|
||||||
|
expect(state.killed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses an install path cmd.exe could reinterpret', async () => {
|
||||||
|
const { spawn } = fakeSpawn()
|
||||||
|
|
||||||
|
const run = createAiturkCliRunner({
|
||||||
|
spawn,
|
||||||
|
existsSync: () => true,
|
||||||
|
env: { LOCALAPPDATA: 'C:\\Users\\a%PATH%b\\AppData\\Local', SystemRoot: 'C:\\Windows' },
|
||||||
|
platform: 'win32'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await run(CLI_KEY, { keepOtherAccount: true })).toBeNull()
|
||||||
|
expect(spawn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
/**
|
||||||
|
* aiturk-login.ts
|
||||||
|
*
|
||||||
|
* One-click TurkServis sign-in for AITURK IDE (device-code flow).
|
||||||
|
*
|
||||||
|
* The IDE shows a short code, the browser opens the turkservis.online approval
|
||||||
|
* page, and once the user approves, the server hands out two API keys exactly
|
||||||
|
* once: one for the IDE's TurkServis provider and one for the AITURK CLI.
|
||||||
|
*
|
||||||
|
* Security contract (do not relax):
|
||||||
|
* - Keys (`tsk_…`) and the device code (`tsdl_…`) never leave the main
|
||||||
|
* process: they are not returned to the renderer, not logged, not put into
|
||||||
|
* error messages, process arguments or URLs.
|
||||||
|
* - The CLI receives its key on stdin only, is launched from an absolute
|
||||||
|
* path without a shell, with a fixed argument list.
|
||||||
|
* - Network calls go only to https://turkservis.online, refuse redirects and
|
||||||
|
* time out after 20 s.
|
||||||
|
*
|
||||||
|
* All I/O is injected so the logic is unit-testable without Electron, a
|
||||||
|
* network or a real CLI (see aiturk-login.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||||
|
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||||
|
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||||
|
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||||
|
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||||
|
|
||||||
|
const AITURK_PROVIDER_ID = 'turkservis'
|
||||||
|
const AITURK_PROVIDER_NAME = 'TurkServis · AITURK'
|
||||||
|
const AITURK_PROVIDER_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||||
|
|
||||||
|
const VERIFICATION_URI_RE = /^https:\/\/turkservis\.online\/account\/aiturk\/device-login\/[0-9a-f-]{36}$/
|
||||||
|
const DEVICE_CODE_RE = /^tsdl_[A-Za-z0-9_-]{43}$/
|
||||||
|
const USER_CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/
|
||||||
|
const API_KEY_RE = /^tsk_[A-Za-z0-9._-]{8,500}$/
|
||||||
|
const MAX_EXPIRES_IN_S = 3600
|
||||||
|
|
||||||
|
export type AiturkCliResult = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||||
|
|
||||||
|
export type AiturkLoginErrorCode =
|
||||||
|
| 'busy'
|
||||||
|
| 'rate-limited'
|
||||||
|
| 'unavailable'
|
||||||
|
| 'invalid-response'
|
||||||
|
| 'browser-failed'
|
||||||
|
| 'provider-failed'
|
||||||
|
| 'no-models'
|
||||||
|
| 'nothing-to-overwrite'
|
||||||
|
|
||||||
|
/** Error whose message is ONLY a fixed code — never upstream text, so a key
|
||||||
|
* echoed by a server or backend cannot ride an error into the renderer/log. */
|
||||||
|
export class AiturkLoginError extends Error {
|
||||||
|
code: AiturkLoginErrorCode
|
||||||
|
|
||||||
|
constructor(code: AiturkLoginErrorCode) {
|
||||||
|
super(`aiturk-login:${code}`)
|
||||||
|
this.name = 'AiturkLoginError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiturkLoginScope {
|
||||||
|
profile?: null | string
|
||||||
|
connectionId?: null | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiturkLoginStartResult {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiturkLoginPollResult =
|
||||||
|
| { state: 'pending' }
|
||||||
|
| { state: 'expired' }
|
||||||
|
| { state: 'connected'; cli: AiturkCliResult }
|
||||||
|
| { state: 'error'; error: AiturkLoginErrorCode }
|
||||||
|
|
||||||
|
export interface AiturkFetchResponse {
|
||||||
|
status: number
|
||||||
|
json: () => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiturkFetch = (
|
||||||
|
url: string,
|
||||||
|
init: { method: string; headers: Record<string, string>; body?: string; redirect: 'error'; signal?: AbortSignal }
|
||||||
|
) => Promise<AiturkFetchResponse>
|
||||||
|
|
||||||
|
export interface AiturkBackendRequest {
|
||||||
|
path: string
|
||||||
|
method: string
|
||||||
|
body: unknown
|
||||||
|
profile?: string
|
||||||
|
connectionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of one CLI launch: 'missing' when the CLI is not installed, the
|
||||||
|
* numeric exit code otherwise, null when it could not run / timed out. */
|
||||||
|
export type AiturkCliExit = 'missing' | null | number
|
||||||
|
|
||||||
|
export interface AiturkLoginDeps {
|
||||||
|
fetch: AiturkFetch
|
||||||
|
/** Open a URL in the system browser. `false` means it could not be opened. */
|
||||||
|
openExternal: (url: string) => boolean | Promise<boolean | void> | void
|
||||||
|
hostname: () => string
|
||||||
|
/** Same route the renderer's hermesApi uses (hermes:api handler internals). */
|
||||||
|
backendRequest: (request: AiturkBackendRequest) => Promise<unknown>
|
||||||
|
/** Run `aiturk login --key-stdin [--keep-other-account]`, key on stdin. */
|
||||||
|
runCli: (cliKey: string, options: { keepOtherAccount: boolean }) => Promise<AiturkCliExit>
|
||||||
|
now: () => number
|
||||||
|
setTimer?: (fn: () => void, ms: number) => unknown
|
||||||
|
clearTimer?: (handle: unknown) => void
|
||||||
|
timeoutSignal?: (ms: number) => AbortSignal | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
deviceCode: string
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresAt: number
|
||||||
|
scope: { profile?: string; connectionId?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceName(raw: unknown): string {
|
||||||
|
// Printable, 1–60 chars. A hostname is not a secret, but keep it tidy.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleaned = String(raw ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60)
|
||||||
|
|
||||||
|
return cleaned || 'AITURK IDE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanScope(scope: unknown): { profile?: string; connectionId?: string } {
|
||||||
|
const source = (scope && typeof scope === 'object' ? scope : {}) as AiturkLoginScope
|
||||||
|
const profile = typeof source.profile === 'string' ? source.profile.trim() : ''
|
||||||
|
const connectionId = typeof source.connectionId === 'string' ? source.connectionId.trim() : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(profile ? { profile } : {}),
|
||||||
|
...(connectionId ? { connectionId } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliResultFromExit(exit: AiturkCliExit, keepOtherAccount: boolean): AiturkCliResult {
|
||||||
|
if (exit === 'missing') {
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exit === 0) {
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exit === 2) {
|
||||||
|
return 'outdated'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exit === 3 && keepOtherAccount) {
|
||||||
|
return 'other-account'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exit === 4) {
|
||||||
|
return 'unknown-account'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||||
|
|
||||||
|
const timeoutSignal =
|
||||||
|
deps.timeoutSignal ?? (ms => (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined))
|
||||||
|
|
||||||
|
let session: null | Session = null
|
||||||
|
// In-flight "approved → save provider → hand key to CLI" work. Concurrent
|
||||||
|
// polls share it so the one-time key is fetched and consumed exactly once.
|
||||||
|
let finishing: null | Promise<AiturkLoginPollResult> = null
|
||||||
|
// Final outcome of the last login, replayed to later polls without a fetch.
|
||||||
|
let settled: null | AiturkLoginPollResult = null
|
||||||
|
// CLI key kept ONLY while the user decides whether to replace another
|
||||||
|
// account's CLI login. Main-process memory, dropped after 5 minutes.
|
||||||
|
let heldCli: null | { key: string; expiresAt: number; timer: unknown } = null
|
||||||
|
|
||||||
|
function dropHeldCli() {
|
||||||
|
if (heldCli) {
|
||||||
|
clearTimer(heldCli.timer)
|
||||||
|
heldCli = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdCli(key: string) {
|
||||||
|
dropHeldCli()
|
||||||
|
|
||||||
|
const timer = setTimer(() => {
|
||||||
|
heldCli = null
|
||||||
|
}, AITURK_CLI_KEY_HOLD_MS)
|
||||||
|
|
||||||
|
// Never keep the app alive just for this timer.
|
||||||
|
;(timer as { unref?: () => void } | null)?.unref?.()
|
||||||
|
heldCli = { key, expiresAt: deps.now() + AITURK_CLI_KEY_HOLD_MS, timer }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path: string, bearer: null | string, body?: unknown): Promise<AiturkFetchResponse> {
|
||||||
|
return deps.fetch(`${AITURK_LOGIN_ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(bearer ? { Authorization: `Bearer ${bearer}` } : {})
|
||||||
|
},
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
redirect: 'error',
|
||||||
|
signal: timeoutSignal(AITURK_LOGIN_FETCH_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelOnServer(deviceCode: string) {
|
||||||
|
try {
|
||||||
|
await post('/v1/aiturk/device-auth/cancel', deviceCode)
|
||||||
|
} catch {
|
||||||
|
// Best effort: the code expires on its own within 10 minutes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(scope?: AiturkLoginScope): Promise<AiturkLoginStartResult> {
|
||||||
|
if (finishing) {
|
||||||
|
throw new AiturkLoginError('busy')
|
||||||
|
}
|
||||||
|
|
||||||
|
// One login at a time: a new start replaces (and revokes) the previous one.
|
||||||
|
const previous = session
|
||||||
|
session = null
|
||||||
|
settled = null
|
||||||
|
dropHeldCli()
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
await cancelOnServer(previous.deviceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: AiturkFetchResponse | undefined
|
||||||
|
|
||||||
|
// A long-idle process can fail its first request on a dead pooled
|
||||||
|
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||||
|
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||||
|
// one start cannot knowingly leave two codes behind. A timeout already
|
||||||
|
// used its whole budget and is not retried.
|
||||||
|
for (let attempt = 1; response === undefined; attempt++) {
|
||||||
|
try {
|
||||||
|
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||||
|
client: 'ide',
|
||||||
|
device_name: deviceName(deps.hostname()),
|
||||||
|
also_cli: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const name = (error as null | { name?: unknown })?.name
|
||||||
|
|
||||||
|
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||||
|
throw new AiturkLoginError('unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
throw new AiturkLoginError('rate-limited')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 201 && response.status !== 200) {
|
||||||
|
throw new AiturkLoginError('unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
throw new AiturkLoginError('invalid-response')
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCode = typeof data?.deviceCode === 'string' ? data.deviceCode : ''
|
||||||
|
const userCode = typeof data?.userCode === 'string' ? data.userCode : ''
|
||||||
|
const verificationUri = typeof data?.verificationUri === 'string' ? data.verificationUri : ''
|
||||||
|
const expiresIn = Number(data?.expiresIn)
|
||||||
|
const codeOk = DEVICE_CODE_RE.test(deviceCode)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!codeOk ||
|
||||||
|
!USER_CODE_RE.test(userCode) ||
|
||||||
|
!VERIFICATION_URI_RE.test(verificationUri) ||
|
||||||
|
!Number.isFinite(expiresIn) ||
|
||||||
|
expiresIn <= 0
|
||||||
|
) {
|
||||||
|
// Never open a URL that is not exactly our approval page.
|
||||||
|
if (codeOk) {
|
||||||
|
await cancelOnServer(deviceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AiturkLoginError('invalid-response')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session || finishing) {
|
||||||
|
// A concurrent start won the race while we were waiting on the network.
|
||||||
|
await cancelOnServer(deviceCode)
|
||||||
|
|
||||||
|
throw new AiturkLoginError('busy')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = deps.now() + Math.min(expiresIn, MAX_EXPIRES_IN_S) * 1000
|
||||||
|
session = { deviceCode, userCode, verificationUri, expiresAt, scope: cleanScope(scope) }
|
||||||
|
|
||||||
|
let opened: unknown
|
||||||
|
|
||||||
|
try {
|
||||||
|
opened = await deps.openExternal(verificationUri)
|
||||||
|
} catch {
|
||||||
|
opened = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opened === false) {
|
||||||
|
session = null
|
||||||
|
await cancelOnServer(deviceCode)
|
||||||
|
|
||||||
|
throw new AiturkLoginError('browser-failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userCode, verificationUri, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProvider(apiKey: string, scope: Session['scope']) {
|
||||||
|
const endpoint = {
|
||||||
|
id: AITURK_PROVIDER_ID,
|
||||||
|
name: AITURK_PROVIDER_NAME,
|
||||||
|
base_url: AITURK_PROVIDER_BASE_URL,
|
||||||
|
api_key: apiKey,
|
||||||
|
discover_models: true,
|
||||||
|
make_default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
let checked: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mirrors the renderer's validateCustomEndpoint (no profile scope) and
|
||||||
|
// saveCustomEndpoint (profile-scoped) calls.
|
||||||
|
checked = await deps.backendRequest({
|
||||||
|
...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
|
||||||
|
path: '/api/providers/custom-endpoints/validate',
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...endpoint, model: '' }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new AiturkLoginError('provider-failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checked?.ok) {
|
||||||
|
throw new AiturkLoginError('provider-failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = Array.isArray(checked.models) ? checked.models.filter((m: unknown) => typeof m === 'string' && m) : []
|
||||||
|
|
||||||
|
if (!models.length) {
|
||||||
|
throw new AiturkLoginError('no-models')
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
saved = await deps.backendRequest({
|
||||||
|
...scope,
|
||||||
|
path: '/api/providers/custom-endpoints',
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...endpoint, model: models[0], models }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new AiturkLoginError('provider-failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved?.ok === false) {
|
||||||
|
throw new AiturkLoginError('provider-failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handCliKey(cliKey: string, keepOtherAccount: boolean): Promise<AiturkCliResult> {
|
||||||
|
let exit: AiturkCliExit
|
||||||
|
|
||||||
|
try {
|
||||||
|
exit = await deps.runCli(cliKey, { keepOtherAccount })
|
||||||
|
} catch {
|
||||||
|
exit = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cliResultFromExit(exit, keepOtherAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish(key: string, cliKey: string, scope: Session['scope']): Promise<AiturkLoginPollResult> {
|
||||||
|
try {
|
||||||
|
await saveProvider(key, scope)
|
||||||
|
} catch (error) {
|
||||||
|
return { state: 'error', error: error instanceof AiturkLoginError ? error.code : 'provider-failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli: AiturkCliResult = 'failed'
|
||||||
|
|
||||||
|
if (cliKey) {
|
||||||
|
cli = await handCliKey(cliKey, true)
|
||||||
|
|
||||||
|
if (cli === 'other-account') {
|
||||||
|
holdCli(cliKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: 'connected', cli }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(): Promise<AiturkLoginPollResult> {
|
||||||
|
if (finishing) {
|
||||||
|
return finishing
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settled) {
|
||||||
|
return settled
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = session
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return { state: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.now() >= current.expiresAt) {
|
||||||
|
session = null
|
||||||
|
|
||||||
|
return { state: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: AiturkFetchResponse
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await post('/v1/aiturk/device-auth/poll', current.deviceCode)
|
||||||
|
} catch {
|
||||||
|
// Transient network trouble: keep waiting until the code expires.
|
||||||
|
return { state: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session !== current) {
|
||||||
|
// Cancelled or replaced while the request was in flight.
|
||||||
|
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
session = null
|
||||||
|
|
||||||
|
return { state: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { state: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
return { state: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session !== current) {
|
||||||
|
return settled ?? (finishing ? finishing : { state: 'expired' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.state !== 'approved') {
|
||||||
|
return { state: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server returns the keys exactly once; the device code is spent.
|
||||||
|
session = null
|
||||||
|
|
||||||
|
const key = typeof data.key === 'string' && API_KEY_RE.test(data.key) ? data.key : ''
|
||||||
|
const cliKey = typeof data.cliKey === 'string' && API_KEY_RE.test(data.cliKey) ? data.cliKey : ''
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
settled = { state: 'error', error: 'invalid-response' }
|
||||||
|
|
||||||
|
return settled
|
||||||
|
}
|
||||||
|
|
||||||
|
finishing = finish(key, cliKey, current.scope)
|
||||||
|
.catch((): AiturkLoginPollResult => ({ state: 'error', error: 'provider-failed' }))
|
||||||
|
.then(result => {
|
||||||
|
settled = result
|
||||||
|
finishing = null
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return finishing
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(): Promise<{ ok: true }> {
|
||||||
|
const current = session
|
||||||
|
session = null
|
||||||
|
dropHeldCli()
|
||||||
|
|
||||||
|
if (!finishing) {
|
||||||
|
settled = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
await cancelOnServer(current.deviceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User confirmed: replace the other account stored in the AITURK CLI. */
|
||||||
|
async function overwriteCli(): Promise<{ cli: AiturkCliResult }> {
|
||||||
|
const held = heldCli
|
||||||
|
|
||||||
|
if (!held || deps.now() >= held.expiresAt) {
|
||||||
|
dropHeldCli()
|
||||||
|
|
||||||
|
throw new AiturkLoginError('nothing-to-overwrite')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single use: drop it before launching so it cannot be replayed.
|
||||||
|
dropHeldCli()
|
||||||
|
|
||||||
|
const cli = await handCliKey(held.key, false)
|
||||||
|
|
||||||
|
if (settled?.state === 'connected') {
|
||||||
|
settled = { state: 'connected', cli }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cli }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, poll, cancel, overwriteCli }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI runner ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiturkCliRunnerDeps {
|
||||||
|
/** child_process.spawn (injected for tests). */
|
||||||
|
spawn: (command: string, args: string[], options: Record<string, unknown>) => any
|
||||||
|
existsSync: (path: string) => boolean
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
platform?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
setTimer?: (fn: () => void, ms: number) => unknown
|
||||||
|
clearTimer?: (handle: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd.exe expands %VAR% and treats " specially even inside quotes; a path that
|
||||||
|
// carries either (or a control char) is refused rather than risk reparsing.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const UNSAFE_CMD_PATH_RE = /["%^!\u0000-\u001f]/
|
||||||
|
const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:\\/
|
||||||
|
|
||||||
|
/** `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`, or null. */
|
||||||
|
export function resolveAiturkCliPath(deps: Pick<AiturkCliRunnerDeps, 'env' | 'existsSync' | 'platform'>): null | string {
|
||||||
|
if ((deps.platform ?? process.platform) !== 'win32') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAppData = String(deps.env.LOCALAPPDATA || '').replace(/[\\/]+$/, '')
|
||||||
|
|
||||||
|
if (!WINDOWS_ABSOLUTE_RE.test(localAppData)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = `${localAppData}\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd`
|
||||||
|
|
||||||
|
try {
|
||||||
|
return deps.existsSync(candidate) ? candidate : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the CLI runner used by createAiturkLogin. The key goes to the child's
|
||||||
|
* stdin and nowhere else: the argument list is a fixed array with no user
|
||||||
|
* input, there is no `shell: true`, and stdout/stderr are discarded.
|
||||||
|
*/
|
||||||
|
export function createAiturkCliRunner(deps: AiturkCliRunnerDeps) {
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
||||||
|
const timeoutMs = deps.timeoutMs ?? AITURK_CLI_TIMEOUT_MS
|
||||||
|
|
||||||
|
return function runCli(cliKey: string, options: { keepOtherAccount: boolean }): Promise<AiturkCliExit> {
|
||||||
|
const cliPath = resolveAiturkCliPath(deps)
|
||||||
|
|
||||||
|
if (!cliPath) {
|
||||||
|
return Promise.resolve('missing')
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||||
|
|
||||||
|
if (UNSAFE_CMD_PATH_RE.test(cliPath) || !WINDOWS_ABSOLUTE_RE.test(systemRoot) || UNSAFE_CMD_PATH_RE.test(systemRoot)) {
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = options.keepOtherAccount ? 'login --key-stdin --keep-other-account' : 'login --key-stdin'
|
||||||
|
|
||||||
|
// A .cmd cannot be spawned directly without a shell; run it through an
|
||||||
|
// absolute cmd.exe. With /s, cmd strips the outer quote pair and runs the
|
||||||
|
// rest verbatim, so the (validated, quoted) path survives spaces.
|
||||||
|
const args = ['/d', '/s', '/c', `""${cliPath}" ${flags}"`]
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let done = false
|
||||||
|
let timer: unknown = null
|
||||||
|
let child: any
|
||||||
|
|
||||||
|
const settle = (value: AiturkCliExit) => {
|
||||||
|
if (done) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done = true
|
||||||
|
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimer(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child = deps.spawn(`${systemRoot}\\System32\\cmd.exe`, args, {
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: true,
|
||||||
|
stdio: ['pipe', 'ignore', 'ignore']
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
settle(null)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimer(() => {
|
||||||
|
try {
|
||||||
|
child.kill()
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(null)
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
child.once('error', () => settle(null))
|
||||||
|
child.once('close', (code: null | number) => settle(typeof code === 'number' ? code : null))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A CLI that exits before reading stdin raises EPIPE here; ignore it,
|
||||||
|
// the exit code is the answer.
|
||||||
|
child.stdin.on('error', () => {})
|
||||||
|
child.stdin.end(`${cliKey}\n`)
|
||||||
|
} catch {
|
||||||
|
// Same as above.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,23 @@ const contentText = value => typeof value === 'string' ? value : Array.isArray(v
|
|||||||
? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : ''
|
? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : ''
|
||||||
|
|
||||||
export function publicMessages(rows) {
|
export function publicMessages(rows) {
|
||||||
return (Array.isArray(rows) ? rows : []).filter(row =>
|
const messages = (Array.isArray(rows) ? rows : []).filter(row =>
|
||||||
['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden'
|
['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden'
|
||||||
).slice(-40).map(row => ({
|
).slice(-40).map(row => ({
|
||||||
id: row.id ?? row.row_id,
|
id: typeof (row.id ?? row.row_id) === 'number' ? (row.id ?? row.row_id) : undefined,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
text: clip(contentText(row.display_content ?? row.content ?? row.text)),
|
text: clip(contentText(row.display_content ?? row.content ?? row.text)),
|
||||||
}))
|
}))
|
||||||
|
// The Chrome native limit counts UTF-8 JSON bytes, not JS characters.
|
||||||
|
// Reserve room for the opening response's in-flight text and metadata;
|
||||||
|
// escaped controls and CJK text must not disconnect a large transcript.
|
||||||
|
const messageBudget = Math.floor((MAX_FRAME - 128000) / Math.max(1, messages.length))
|
||||||
|
for (const message of messages) {
|
||||||
|
while (Buffer.byteLength(JSON.stringify(message)) > messageBudget) {
|
||||||
|
message.text = message.text.slice(0, Math.floor(message.text.length * 0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateRequest(message) {
|
export function validateRequest(message) {
|
||||||
@@ -149,10 +159,23 @@ export class ChromeGatewayClient {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
||||||
case 'sessions.list': {
|
case 'sessions.list': {
|
||||||
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
const rows = [], seen = new Set()
|
||||||
const data = await this.rest(`/api/sessions?${query}`)
|
// The real sessions API validates limit <= 100. Filter before paging:
|
||||||
const rows = Array.isArray(data) ? data : data.sessions || []
|
// cron/CLI traffic must not crowd IDE conversations out of the list.
|
||||||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
|
for (let offset = 0; offset < 200; offset += 100) {
|
||||||
|
const query = new URLSearchParams({ ...this.scope(), limit: '100', offset: String(offset),
|
||||||
|
source: 'desktop', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||||
|
const data = await this.rest(`/api/sessions?${query}`)
|
||||||
|
const page = Array.isArray(data) ? data : data.sessions || []
|
||||||
|
for (const row of page) {
|
||||||
|
const id = row.id || row.session_id
|
||||||
|
if (row.source !== 'desktop' || !id || seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
if (page.length < 100) break
|
||||||
|
}
|
||||||
|
return { sessions: rows.slice(0, 200).map(row => ({
|
||||||
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
|
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,
|
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
||||||
})), profile: this.backend.profile || 'default', limited: true }
|
})), profile: this.backend.profile || 'default', limited: true }
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { PassThrough } from 'node:stream'
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||||
|
import controlSchema from '../codex-link-control.schema.json'
|
||||||
|
import { CODEX_LINK_CONTROL_SCHEMA_HASH } from '../codex-link-control.types'
|
||||||
|
import type { CodexLinkControlRequest } from '../codex-link-control.types'
|
||||||
|
import { canonicalJson } from '../codex-link-json'
|
||||||
|
|
||||||
|
import { createCodexLinkClient } from './codex-link-client'
|
||||||
|
|
||||||
|
const request: CodexLinkControlRequest = {
|
||||||
|
contract_version: 'codex-link/1',
|
||||||
|
kind: 'settings_get',
|
||||||
|
request_id: 'req-1',
|
||||||
|
operation_id: 'op-1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
...request,
|
||||||
|
kind: 'settings_result',
|
||||||
|
settings: {
|
||||||
|
settings_revision: 1,
|
||||||
|
enabled: true,
|
||||||
|
compression_enabled: true,
|
||||||
|
threshold_tokens: 250000,
|
||||||
|
source_binding_handle: null,
|
||||||
|
compact_route_id: null,
|
||||||
|
legacy_intent_imported: false,
|
||||||
|
ownership_manifest_revision: 0,
|
||||||
|
activation_state: 'pending_runtime_acceptance'
|
||||||
|
},
|
||||||
|
available_sources: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(reply: string = JSON.stringify(response), env: Record<string, string> = {}) {
|
||||||
|
let stdin = ''
|
||||||
|
|
||||||
|
const child = Object.assign(new EventEmitter(), {
|
||||||
|
stdout: new PassThrough(),
|
||||||
|
stderr: new PassThrough(),
|
||||||
|
stdin: new PassThrough(),
|
||||||
|
kill: vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stdin.on('data', chunk => {
|
||||||
|
stdin += chunk.toString()
|
||||||
|
})
|
||||||
|
child.stdin.on('finish', () =>
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const bytes = Buffer.from(reply)
|
||||||
|
|
||||||
|
// UTF-8 chunks may end inside a multi-byte character.
|
||||||
|
for (const byte of bytes) {
|
||||||
|
child.stdout.write(Buffer.from([byte]))
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stderr.write('diagnostic that must not become a response')
|
||||||
|
child.emit('close', 0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const spawn = vi.fn(() => child)
|
||||||
|
|
||||||
|
const control = createCodexLinkClient({
|
||||||
|
spawn,
|
||||||
|
existsSync: () => true,
|
||||||
|
platform: 'win32',
|
||||||
|
env: { LOCALAPPDATA: 'C:\\Users\\Example User\\AppData\\Local', SystemRoot: 'C:\\Windows', ...env }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { control, spawn, child, stdin: () => stdin }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Codex connection control boundary', () => {
|
||||||
|
it('pins the generated control-only schema', () => {
|
||||||
|
expect('sha256:' + createHash('sha256').update(canonicalJson(controlSchema)).digest('hex')).toBe(CODEX_LINK_CONTROL_SCHEMA_HASH)
|
||||||
|
expect(controlSchema.$defs).not.toHaveProperty('runtime_attach')
|
||||||
|
expect(controlSchema.$defs).not.toHaveProperty('checkpoint_commit')
|
||||||
|
})
|
||||||
|
it('uses only fixed command arguments and sends the exact request on stdin with EOF', async () => {
|
||||||
|
const h = harness()
|
||||||
|
expect(await h.control(request)).toEqual(response)
|
||||||
|
expect(h.stdin()).toBe(JSON.stringify(request) + '\n')
|
||||||
|
const [file, args, options] = h.spawn.mock.calls[0] as unknown as [string, string[], any]
|
||||||
|
expect(file).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||||
|
expect(args).toEqual([
|
||||||
|
'/d',
|
||||||
|
'/s',
|
||||||
|
'/c',
|
||||||
|
'""C:\\Users\\Example User\\AppData\\Local\\TurkServis\\AITURK\\program\\bin\\aiturk.cmd" codex-link control --stdin"'
|
||||||
|
])
|
||||||
|
expect(options).toMatchObject({ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
|
})
|
||||||
|
it('rejects runtime and arbitrary path/credential commands before spawning', async () => {
|
||||||
|
const h = harness()
|
||||||
|
await expect(h.control({ ...request, kind: 'runtime_attach' })).rejects.toThrow()
|
||||||
|
await expect(h.control({ ...request, path: 'C:\\private' })).rejects.toThrow()
|
||||||
|
expect(h.spawn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('rejects shell expansion in the entrypoint path', async () => {
|
||||||
|
const h = harness(undefined, { LOCALAPPDATA: 'C:\\Users\\%PATH%\\Local' })
|
||||||
|
await expect(h.control(request)).rejects.toThrow('invalid_entrypoint')
|
||||||
|
expect(h.spawn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('binds the response to both request and operation identity', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseControlResponse(JSON.stringify({ ...response, operation_id: 'other-chat-op' }), request)
|
||||||
|
).toThrow()
|
||||||
|
expect(() => parseControlResponse(JSON.stringify({ ...response, request_id: 'other-request' }), request)).toThrow()
|
||||||
|
})
|
||||||
|
it('rejects duplicate keys, floats, trailing JSON and invalid UTF8', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseControlResponse(
|
||||||
|
JSON.stringify(response).replace('"settings_revision":1', '"settings_revision":1,"settings_revision":2'),
|
||||||
|
request
|
||||||
|
)
|
||||||
|
).toThrow('duplicate_json_key')
|
||||||
|
expect(() => parseControlResponse(JSON.stringify(response).replace('250000', '250000.0'), request)).toThrow(
|
||||||
|
'floats_or_non_json_type'
|
||||||
|
)
|
||||||
|
expect(() => parseControlResponse(JSON.stringify(response) + '{}', request)).toThrow()
|
||||||
|
expect(() => parseControlResponse(new Uint8Array([0xc3, 0x28]), request)).toThrow()
|
||||||
|
})
|
||||||
|
it('rejects oversized requests and unsafe integer settings', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateControlRequest({
|
||||||
|
...request,
|
||||||
|
kind: 'settings_update',
|
||||||
|
expected_settings_revision: 1,
|
||||||
|
patch: { threshold_tokens: Number.MAX_SAFE_INTEGER + 1 }
|
||||||
|
})
|
||||||
|
).toThrow()
|
||||||
|
expect(() => validateControlRequest({ ...request, operation_id: 'x'.repeat(70000) })).toThrow()
|
||||||
|
})
|
||||||
|
it('does not retry a write after a broken reply', async () => {
|
||||||
|
const h = harness('broken')
|
||||||
|
await expect(h.control({ ...request, kind: 'catalog_sync' })).rejects.toThrow('outcome_unknown')
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it('preserves a structured CLI rejection returned with exit code one', async () => {
|
||||||
|
const rejection = { ...request, kind: 'control_error', settings_revision: 2, error: { code: 'stale_settings_cas', message: 'Ayarlar değişti.', recovery_action: 'retry_control_read' } }
|
||||||
|
const h = harness()
|
||||||
|
h.child.stdin.removeAllListeners('finish')
|
||||||
|
h.child.stdin.on('finish', () => queueMicrotask(() => {
|
||||||
|
h.child.stdout.write(JSON.stringify(rejection))
|
||||||
|
h.child.emit('close', 1)
|
||||||
|
}))
|
||||||
|
expect(await h.control(request)).toEqual(rejection)
|
||||||
|
expect(h.spawn).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
it('preserves exact Unicode when response bytes arrive separately', async () => {
|
||||||
|
const h = harness(
|
||||||
|
JSON.stringify({
|
||||||
|
...response,
|
||||||
|
available_sources: [{ source_binding_handle: 'source-a', label: 'Türkçe é/é 🧪', authorized: true }]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await h.control(request)
|
||||||
|
expect(result.kind === 'settings_result' && result.available_sources[0].label).toBe('Türkçe é/é 🧪')
|
||||||
|
})
|
||||||
|
it('bounds stdout and terminates only the short proxy', async () => {
|
||||||
|
const h = harness('x'.repeat(1024 * 1024 + 1))
|
||||||
|
// Avoid a million small chunks: replace the synthetic finish listener.
|
||||||
|
h.child.stdin.removeAllListeners('finish')
|
||||||
|
h.child.stdin.on('finish', () => queueMicrotask(() => h.child.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1))))
|
||||||
|
await expect(h.control(request)).rejects.toThrow('outcome_unknown')
|
||||||
|
expect(h.child.kill).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { CODEX_LINK_RESPONSE_BYTES, parseControlResponse, validateControlRequest } from '../codex-link-control'
|
||||||
|
import type { CodexLinkControlResponse } from '../codex-link-control.types'
|
||||||
|
|
||||||
|
import { type AiturkCliRunnerDeps, resolveAiturkCliPath } from './aiturk-login'
|
||||||
|
|
||||||
|
type Dependencies = AiturkCliRunnerDeps
|
||||||
|
// Only the fixed entrypoint and fixed arguments go through cmd.exe. All values travel on stdin.
|
||||||
|
// eslint-disable-next-line no-control-regex -- Reject control bytes in executable paths before cmd parsing.
|
||||||
|
const UNSAFE_PATH = /["%^!\u0000-\u001f]/
|
||||||
|
const ABSOLUTE_PATH = /^[A-Za-z]:\\/
|
||||||
|
|
||||||
|
export function createCodexLinkClient(deps: Dependencies) {
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType<typeof setTimeout>))
|
||||||
|
|
||||||
|
return async function control(raw: unknown): Promise<CodexLinkControlResponse> {
|
||||||
|
validateControlRequest(raw)
|
||||||
|
const cli = resolveAiturkCliPath(deps)
|
||||||
|
|
||||||
|
if (!cli) {
|
||||||
|
throw new Error('codex-link:cli_unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemRoot = String(deps.env.SystemRoot || deps.env.SYSTEMROOT || 'C:\\Windows').replace(/[\\/]+$/, '')
|
||||||
|
|
||||||
|
if (UNSAFE_PATH.test(cli) || UNSAFE_PATH.test(systemRoot) || !ABSOLUTE_PATH.test(systemRoot)) {
|
||||||
|
throw new Error('codex-link:invalid_entrypoint')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
let timer: unknown
|
||||||
|
let bytes = 0
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let child: any
|
||||||
|
|
||||||
|
const finish = (error?: Error, response?: CodexLinkControlResponse) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true
|
||||||
|
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimer(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
error ? reject(error) : resolve(response!)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child = deps.spawn(
|
||||||
|
`${systemRoot}\\System32\\cmd.exe`,
|
||||||
|
['/d', '/s', '/c', `""${cli}" codex-link control --stdin"`],
|
||||||
|
{
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: true,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
finish(new Error('codex-link:cli_unavailable'))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimer(() => {
|
||||||
|
// The durable manager may have accepted a write. Never repeat it under a new ID.
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill()
|
||||||
|
} catch {
|
||||||
|
/* proxy already exited; manager is independent */
|
||||||
|
}
|
||||||
|
}, deps.timeoutMs ?? 30_000)
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes += chunk.length
|
||||||
|
|
||||||
|
if (bytes > CODEX_LINK_RESPONSE_BYTES) {
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill()
|
||||||
|
} catch {
|
||||||
|
/* no process tree termination */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Drain concurrently, with no history, paths, credentials or uncontrolled diagnostics logged.
|
||||||
|
child.stderr.on('data', () => {})
|
||||||
|
child.stdin.on('error', () => {})
|
||||||
|
child.once('error', () => finish(new Error('codex-link:outcome_unknown')))
|
||||||
|
child.once('close', (code: number | null) => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0 && code !== 1) {
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = parseControlResponse(Buffer.concat(chunks), raw)
|
||||||
|
|
||||||
|
if (code === 1 && response.kind !== 'control_error') {
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
} else {
|
||||||
|
finish(undefined, response)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.stdin.end(JSON.stringify(raw) + '\n')
|
||||||
|
} catch {
|
||||||
|
finish(new Error('codex-link:outcome_unknown'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,3 +131,37 @@ test('remote apply without a waiter has no first-run side effects', async () =>
|
|||||||
assert.equal(hidden, 0)
|
assert.equal(hidden, 0)
|
||||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a completed shared runtime wakes only its own pending setup, without granting installation consent', async () => {
|
||||||
|
let hidden = 0
|
||||||
|
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||||
|
const pending = gate.wait(bootstrapBackend)
|
||||||
|
assert.equal(gate.runtimeAvailable('/tmp/unrelated-runtime'), false)
|
||||||
|
assert.equal(await settledState(pending), 'pending')
|
||||||
|
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), true)
|
||||||
|
assert.equal(await pending, 'runtime-ready')
|
||||||
|
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||||
|
assert.equal(hidden, 1)
|
||||||
|
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late runtime readiness does not revive a reset or remotely resolved waiter', async () => {
|
||||||
|
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||||
|
const pending = gate.wait(bootstrapBackend)
|
||||||
|
gate.resetForRetry()
|
||||||
|
assert.equal(await pending, 'reset')
|
||||||
|
assert.equal(gate.runtimeAvailable(bootstrapBackend.activeRoot), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runtime completed before wait does not leave a stale setup prompt pending', async () => {
|
||||||
|
let prompts = 0
|
||||||
|
const gate = createFirstRunSetupGate({
|
||||||
|
isRuntimeAvailable: backend => backend.activeRoot === bootstrapBackend.activeRoot,
|
||||||
|
promptChoice: () => prompts++,
|
||||||
|
stuckAfterMs: 0
|
||||||
|
})
|
||||||
|
assert.equal(await gate.wait(bootstrapBackend), 'runtime-ready')
|
||||||
|
assert.equal(gate.hasWaiter(), false)
|
||||||
|
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||||
|
assert.equal(prompts, 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
interface FirstRunSetupBackend {
|
interface FirstRunSetupBackend {
|
||||||
activeRoot?: string
|
activeRoot?: string
|
||||||
kind?: string
|
kind?: string
|
||||||
@@ -5,6 +7,7 @@ interface FirstRunSetupBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FirstRunSetupGateOptions {
|
interface FirstRunSetupGateOptions {
|
||||||
|
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
|
||||||
hideChoice?: () => void
|
hideChoice?: () => void
|
||||||
log?: (message: string) => void
|
log?: (message: string) => void
|
||||||
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
||||||
@@ -12,9 +15,10 @@ interface FirstRunSetupGateOptions {
|
|||||||
stuckAfterMs?: number
|
stuckAfterMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
|
||||||
|
|
||||||
export function createFirstRunSetupGate({
|
export function createFirstRunSetupGate({
|
||||||
|
isRuntimeAvailable,
|
||||||
hideChoice,
|
hideChoice,
|
||||||
log,
|
log,
|
||||||
onStuck,
|
onStuck,
|
||||||
@@ -24,6 +28,7 @@ export function createFirstRunSetupGate({
|
|||||||
let localBootstrapConfirmed = false
|
let localBootstrapConfirmed = false
|
||||||
|
|
||||||
let waiter: {
|
let waiter: {
|
||||||
|
activeRoot?: string
|
||||||
promise: Promise<FirstRunSetupDecision>
|
promise: Promise<FirstRunSetupDecision>
|
||||||
resolve: (decision: FirstRunSetupDecision) => void
|
resolve: (decision: FirstRunSetupDecision) => void
|
||||||
} | null = null
|
} | null = null
|
||||||
@@ -65,6 +70,13 @@ export function createFirstRunSetupGate({
|
|||||||
return 'continue-local' as const
|
return 'continue-local' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recheck synchronously before registering the waiter: another profile
|
||||||
|
// may have completed installation before this stale descriptor arrived.
|
||||||
|
if (backend && isRuntimeAvailable?.(backend)) {
|
||||||
|
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
|
||||||
|
return 'runtime-ready' as const
|
||||||
|
}
|
||||||
|
|
||||||
if (waiter) {
|
if (waiter) {
|
||||||
return waiter.promise
|
return waiter.promise
|
||||||
}
|
}
|
||||||
@@ -78,7 +90,7 @@ export function createFirstRunSetupGate({
|
|||||||
resolveWaiter = resolve
|
resolveWaiter = resolve
|
||||||
})
|
})
|
||||||
|
|
||||||
waiter = { promise, resolve: resolveWaiter }
|
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
|
||||||
|
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
@@ -102,6 +114,21 @@ export function createFirstRunSetupGate({
|
|||||||
settleWaiter('continue-local')
|
settleWaiter('continue-local')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A sibling profile may finish installing the SAME shared runtime while the
|
||||||
|
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
|
||||||
|
// this as permission for another installation, or reuse the sibling's
|
||||||
|
// profile/backend descriptor.
|
||||||
|
const runtimeAvailable = (root: string) => {
|
||||||
|
const canonical = (value: string) => {
|
||||||
|
const absolute = path.resolve(value)
|
||||||
|
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
||||||
|
}
|
||||||
|
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
|
||||||
|
settleWaiter('runtime-ready')
|
||||||
|
hideChoice?.()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const resetForRetry = () => {
|
const resetForRetry = () => {
|
||||||
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
||||||
// Settle the old boot explicitly so it cannot fall through into local
|
// Settle the old boot explicitly so it cannot fall through into local
|
||||||
@@ -140,6 +167,7 @@ export function createFirstRunSetupGate({
|
|||||||
isLocalBootstrapConfirmed,
|
isLocalBootstrapConfirmed,
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
resetForRetry,
|
resetForRetry,
|
||||||
|
runtimeAvailable,
|
||||||
shouldGate,
|
shouldGate,
|
||||||
wait
|
wait
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execFileSync, spawn } from 'node:child_process'
|
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
@@ -6,8 +6,10 @@ import https from 'node:https'
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import tls from 'node:tls'
|
import tls from 'node:tls'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||||
import { startChromeBridge } from './chrome-bridge.mjs'
|
import { startChromeBridge } from './chrome-bridge.mjs'
|
||||||
|
import { createMobileWindow } from './mobile-host.mjs'
|
||||||
|
import { MobileVault } from './mobile-secrets.mjs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@@ -30,8 +32,21 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
systemPreferences
|
systemPreferences
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
|
import { WebSocket as RelayWebSocket } from 'ws'
|
||||||
|
|
||||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||||
|
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||||
|
import { createCodexLinkClient } from './codex-link-client'
|
||||||
|
import { createMobileBackendResolver } from './mobile-backend'
|
||||||
|
import {
|
||||||
|
createMobileLink,
|
||||||
|
createMobileLinkFileStore,
|
||||||
|
createWindowsFirewall,
|
||||||
|
defaultRouteAddress,
|
||||||
|
type MobileLink,
|
||||||
|
selectLanAddresses
|
||||||
|
} from './mobile-lan-proxy'
|
||||||
|
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||||
@@ -2062,6 +2077,10 @@ function hideFirstRunSetupChoice() {
|
|||||||
function getFirstRunSetupGate() {
|
function getFirstRunSetupGate() {
|
||||||
if (!firstRunSetupGate) {
|
if (!firstRunSetupGate) {
|
||||||
firstRunSetupGate = createFirstRunSetupGate({
|
firstRunSetupGate = createFirstRunSetupGate({
|
||||||
|
isRuntimeAvailable: backend =>
|
||||||
|
backend.activeRoot === ACTIVE_HERMES_ROOT &&
|
||||||
|
resolveHermesBackend(['--profile', primaryProfileKey(), 'serve', '--host', '127.0.0.1', '--port', '0']).kind !==
|
||||||
|
'bootstrap-needed',
|
||||||
hideChoice: hideFirstRunSetupChoice,
|
hideChoice: hideFirstRunSetupChoice,
|
||||||
log: rememberLog,
|
log: rememberLog,
|
||||||
onStuck: (_backend, stuckAfterMs) => {
|
onStuck: (_backend, stuckAfterMs) => {
|
||||||
@@ -5220,6 +5239,22 @@ async function ensureRuntime(backend) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice wheels (onnxruntime, ctranslate2) need msvcp140*.dll, which the
|
||||||
|
// managed CPython does not ship. Place the bundled Microsoft CRT beside the
|
||||||
|
// managed python.exe before the backend imports anything. Never blocks boot:
|
||||||
|
// only the voice features depend on it.
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
const vcRuntime = ensureWindowsVcRuntime({
|
||||||
|
platform: process.platform,
|
||||||
|
venvRoot: VENV_ROOT,
|
||||||
|
sourceDir: IS_PACKAGED
|
||||||
|
? path.join(process.resourcesPath, 'vc-runtime', 'x64')
|
||||||
|
: path.join(APP_ROOT, 'assets', 'windows', 'vc-runtime', 'x64')
|
||||||
|
})
|
||||||
|
|
||||||
|
rememberLog(`[vc-runtime] ${describeVcRuntimeResult(vcRuntime)}`)
|
||||||
|
}
|
||||||
|
|
||||||
backend.command = getVenvPython(VENV_ROOT)
|
backend.command = getVenvPython(VENV_ROOT)
|
||||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||||
updateBootProgress({
|
updateBootProgress({
|
||||||
@@ -5230,6 +5265,11 @@ async function ensureRuntime(backend) {
|
|||||||
error: null
|
error: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Runtime installation is shared; profile processes and their data are not.
|
||||||
|
// Wake only a setup waiter for this exact installed root, then let primary
|
||||||
|
// startup resolve its own profile again (never adopt a pool connection).
|
||||||
|
firstRunSetupGate?.runtimeAvailable(ACTIVE_HERMES_ROOT)
|
||||||
|
|
||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6766,7 +6806,7 @@ function buildApplicationMenu() {
|
|||||||
|
|
||||||
const checkForUpdatesItem = {
|
const checkForUpdatesItem = {
|
||||||
label: 'Check for Updates…',
|
label: 'Check for Updates…',
|
||||||
click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested()
|
click: () => (IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_MAC) {
|
if (IS_MAC) {
|
||||||
@@ -17213,6 +17253,63 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── AITURK one-click sign-in (device code) ───────────────────────────────
|
||||||
|
// The API keys returned by turkservis.online stay in THIS process: the
|
||||||
|
// provider is saved through the same backend route hermes:api uses and the
|
||||||
|
// CLI key is piped to `aiturk login --key-stdin`. The renderer only ever sees
|
||||||
|
// the short user code, the approval URL and a status. See aiturk-login.ts.
|
||||||
|
const aiturkLogin = createAiturkLogin({
|
||||||
|
fetch: (url, init) => fetch(url, init),
|
||||||
|
openExternal: url => openExternalUrl(url),
|
||||||
|
hostname: () => os.hostname(),
|
||||||
|
backendRequest: request => handleHermesApiRequest(request),
|
||||||
|
runCli: createAiturkCliRunner({ spawn, existsSync: fs.existsSync, env: process.env }),
|
||||||
|
now: () => Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexLinkControl = createCodexLinkClient({ spawn, existsSync: fs.existsSync, env: process.env })
|
||||||
|
ipcMain.handle('aiturk:codex-link:control', async (event, request) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!window || (window !== mainWindow && !instanceWindows.has(window)) ||
|
||||||
|
event.senderFrame !== event.sender.mainFrame) {
|
||||||
|
throw new Error('codex-link:unavailable')
|
||||||
|
}
|
||||||
|
return codexLinkControl(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only the primary window's top-level frame may drive a sign-in.
|
||||||
|
function assertAiturkLoginSender(event) {
|
||||||
|
const contents = mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null
|
||||||
|
|
||||||
|
if (!contents || event.sender !== contents || (event.senderFrame && event.senderFrame !== contents.mainFrame)) {
|
||||||
|
throw new Error('aiturk-login:unavailable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('aiturk:login:start', async (event, scope) => {
|
||||||
|
assertAiturkLoginSender(event)
|
||||||
|
|
||||||
|
return aiturkLogin.start(scope)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('aiturk:login:poll', async event => {
|
||||||
|
assertAiturkLoginSender(event)
|
||||||
|
|
||||||
|
return aiturkLogin.poll()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('aiturk:login:cancel', async event => {
|
||||||
|
assertAiturkLoginSender(event)
|
||||||
|
|
||||||
|
return aiturkLogin.cancel()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('aiturk:login:overwrite-cli', async event => {
|
||||||
|
assertAiturkLoginSender(event)
|
||||||
|
|
||||||
|
return aiturkLogin.overwriteCli()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
// ── Find-in-page (Ctrl/Cmd+F) ─────────────────────────────────────────────
|
||||||
// The desktop supports multiple BrowserWindows (one primary plus any
|
// The desktop supports multiple BrowserWindows (one primary plus any
|
||||||
// per-session secondary windows spawned via `hermes:window:openSession`).
|
// per-session secondary windows spawned via `hermes:window:openSession`).
|
||||||
@@ -17570,8 +17667,11 @@ async function getUninstallSummary() {
|
|||||||
async function runDesktopUninstall(_mode) {
|
async function runDesktopUninstall(_mode) {
|
||||||
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
// The upstream cleanup script removes Hermes-specific shortcuts, registry
|
||||||
// entries and global launchers. AITURK is owned by its OS package manager.
|
// entries and global launchers. AITURK is owned by its OS package manager.
|
||||||
return { ok: false, error: 'aiturk-os-uninstaller',
|
return {
|
||||||
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.' }
|
ok: false,
|
||||||
|
error: 'aiturk-os-uninstaller',
|
||||||
|
message: 'AITURK IDE işletim sisteminin uygulama yöneticisinden kaldırılır.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||||
@@ -17717,6 +17817,10 @@ if (!isPrimaryInstance) {
|
|||||||
app.exit(0)
|
app.exit(0)
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
if (process.platform === 'win32' && argv.includes('--aiturk-mobile')) {
|
||||||
|
openMobileConnectionWindow()
|
||||||
|
return
|
||||||
|
}
|
||||||
const url = _extractDeepLink(argv)
|
const url = _extractDeepLink(argv)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -17740,7 +17844,119 @@ app.on('open-url', (event, url) => {
|
|||||||
handleDeepLink(url)
|
handleDeepLink(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let mobileConnectionWindow: BrowserWindow | null = null
|
||||||
|
function openMobileConnectionWindow() {
|
||||||
|
if (mobileConnectionWindow && !mobileConnectionWindow.isDestroyed()) {
|
||||||
|
mobileConnectionWindow.show()
|
||||||
|
mobileConnectionWindow.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mobileConnectionWindow = createMobileWindow({
|
||||||
|
assets: path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
link: getMobileLink()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of
|
||||||
|
// the loopback backend + pairing. One controller for the whole app run; it only
|
||||||
|
// listens while the feature is on and a phone is paired or being paired.
|
||||||
|
let mobileLink: MobileLink | null = null
|
||||||
|
|
||||||
|
function mobileLinkRemoteMode() {
|
||||||
|
return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileLink(): MobileLink {
|
||||||
|
if (mobileLink) {
|
||||||
|
return mobileLink
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile')
|
||||||
|
|
||||||
|
mobileLink = createMobileLink({
|
||||||
|
codexLinkControl,
|
||||||
|
// Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept.
|
||||||
|
vault: new MobileVault(directory, safeStorage),
|
||||||
|
store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises),
|
||||||
|
resolveBackend: createMobileBackendResolver({
|
||||||
|
isRemote: profile => mobileLinkRemoteMode() || profileHasRemoteOverride(profile),
|
||||||
|
runtimeNeedsInstall: profile =>
|
||||||
|
resolveHermesBackend(['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']).kind ===
|
||||||
|
'bootstrap-needed',
|
||||||
|
ensureBackend
|
||||||
|
}),
|
||||||
|
isRemoteMode: mobileLinkRemoteMode,
|
||||||
|
firewall: createWindowsFirewall({
|
||||||
|
spawn,
|
||||||
|
platform: process.platform,
|
||||||
|
env: process.env,
|
||||||
|
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||||
|
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||||
|
run: (file, args) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
execFile(
|
||||||
|
file,
|
||||||
|
args,
|
||||||
|
{ windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 },
|
||||||
|
(error, stdout) => {
|
||||||
|
const code = error
|
||||||
|
? typeof (error as { code?: unknown }).code === 'number'
|
||||||
|
? ((error as { code?: unknown }).code as number)
|
||||||
|
: null
|
||||||
|
: 0
|
||||||
|
|
||||||
|
resolve({ code, stdout: String(stdout ?? '') })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||||
|
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||||
|
WebSocket: RelayWebSocket,
|
||||||
|
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||||
|
version: app.getVersion(),
|
||||||
|
pcName: os.hostname(),
|
||||||
|
platform: process.platform,
|
||||||
|
// Method + path + status only (no token, code, query or body). Successful
|
||||||
|
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||||
|
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||||
|
log: line => {
|
||||||
|
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||||
|
rememberLog(`[mobile-lan] ${line}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mobileLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the primary window's top-level frame may open the phone window.
|
||||||
|
ipcMain.handle('aiturk:mobile:open', async event => {
|
||||||
|
assertAiturkLoginSender(event)
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
throw new Error('aiturk-mobile:unsupported')
|
||||||
|
}
|
||||||
|
|
||||||
|
openMobileConnectionWindow()
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
void mobileLink?.stop().catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// Explicit development/beta entry point; never enables remote access on boot.
|
||||||
|
if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow()
|
||||||
|
// Resume the phone link only when the user turned it on earlier AND a phone
|
||||||
|
// is paired; otherwise nothing listens (and no vault / UAC prompt is touched).
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
void getMobileLink()
|
||||||
|
.start()
|
||||||
|
.catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`))
|
||||||
|
}
|
||||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||||
void startChromeBridge({
|
void startChromeBridge({
|
||||||
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'),
|
||||||
@@ -17751,7 +17967,12 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
return ensureBackend(profile)
|
return ensureBackend(profile)
|
||||||
}
|
}
|
||||||
}).then(stop => app.once('will-quit', () => { void stop() }))
|
})
|
||||||
|
.then(stop =>
|
||||||
|
app.once('will-quit', () => {
|
||||||
|
void stop()
|
||||||
|
})
|
||||||
|
)
|
||||||
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
|
.catch(() => rememberLog('[chrome] Native bridge could not start.'))
|
||||||
}
|
}
|
||||||
// Warm the login-shell PATH resolution immediately so it usually completes
|
// Warm the login-shell PATH resolution immediately so it usually completes
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { ChromeGatewayClient } from './chrome-bridge.mjs'
|
||||||
|
|
||||||
|
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||||
|
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
|
||||||
|
|
||||||
|
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
|
||||||
|
// Journal stores only hashes and delivery state, no prompt, token, or result.
|
||||||
|
export class DeliveryJournal {
|
||||||
|
constructor(directory) { this.directory = directory; this.writes = Promise.resolve() }
|
||||||
|
async reserve(deviceId, taskId, envelope) {
|
||||||
|
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
|
||||||
|
await mkdir(this.directory, { recursive: true })
|
||||||
|
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
|
||||||
|
const hash = digest(envelope)
|
||||||
|
try {
|
||||||
|
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
|
||||||
|
return { file, hash, fresh: true }
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EEXIST') throw error
|
||||||
|
let previous
|
||||||
|
try { previous = JSON.parse(await readFile(file, 'utf8')) }
|
||||||
|
catch { return { file, hash, fresh: false, state: 'unknown' } }
|
||||||
|
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
|
||||||
|
return { file, hash, fresh: false, state: previous.state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async accepted(reservation) {
|
||||||
|
return this.update(reservation, 'accepted')
|
||||||
|
}
|
||||||
|
update(reservation, state) {
|
||||||
|
const operation = this.writes.then(async () => {
|
||||||
|
const previous = JSON.parse(await readFile(reservation.file, 'utf8'))
|
||||||
|
if (['complete', 'error', 'interrupted'].includes(previous.state)) return
|
||||||
|
const temporary = `${reservation.file}.tmp`
|
||||||
|
await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true })
|
||||||
|
await rename(temporary, reservation.file)
|
||||||
|
})
|
||||||
|
this.writes = operation.catch(() => {})
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MobileGatewayClient extends ChromeGatewayClient {
|
||||||
|
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
|
||||||
|
super(resolveBackend, emit, webSocket)
|
||||||
|
this.deviceId = deviceId
|
||||||
|
this.journal = journal
|
||||||
|
this.authorize = authorize
|
||||||
|
this.approval = null
|
||||||
|
this.model = null
|
||||||
|
this.chain = Promise.resolve()
|
||||||
|
this.tasks = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(event) {
|
||||||
|
if (['task.start', 'task.complete'].includes(event?.type)) {
|
||||||
|
const task = this.tasks.get(event.payload?.task_id)
|
||||||
|
if (!task || task.runtimeId !== event.session_id || task.terminal) return
|
||||||
|
const state = event.type === 'task.start' ? 'running' : event.payload.status
|
||||||
|
if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return
|
||||||
|
task.terminal = state !== 'running'
|
||||||
|
task.state = state
|
||||||
|
if (task.terminal) this.journal.update(task.reservation, state).catch(() => {})
|
||||||
|
this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state,
|
||||||
|
text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
|
||||||
|
if (event.type === 'approval.request') {
|
||||||
|
this.approval = this.approvalView(event.payload)
|
||||||
|
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === 'message.complete') this.approval = null
|
||||||
|
super.onEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalView(payload) {
|
||||||
|
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
|
||||||
|
// Retain full backend-redacted operation; never approve a clipped preview.
|
||||||
|
if (payload.command.length > 48000) return null
|
||||||
|
return { requestId: payload.request_id, command: payload.command,
|
||||||
|
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
||||||
|
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(message) {
|
||||||
|
const result = this.chain.then(() => this.perform(message))
|
||||||
|
this.chain = result.catch(() => {})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(message) {
|
||||||
|
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
|
||||||
|
await this.authorize()
|
||||||
|
const params = message.params || {}
|
||||||
|
if (message.action === 'session.open') {
|
||||||
|
const list = await super.handle({ id: message.id, action: 'sessions.list' })
|
||||||
|
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
|
||||||
|
const result = await super.handle(message)
|
||||||
|
this.model = result.model
|
||||||
|
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||||
|
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||||
|
this.approval = this.approvalView(resumed.pending_approval)
|
||||||
|
return { ...result, approval: this.approval }
|
||||||
|
}
|
||||||
|
if (message.action === 'task.submit') {
|
||||||
|
const capabilities = await this.rpc('gateway.capabilities', {})
|
||||||
|
if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.')
|
||||||
|
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
|
||||||
|
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
|
||||||
|
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
|
||||||
|
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
|
||||||
|
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
|
||||||
|
// A model change by the desktop between selection and delivery fails closed.
|
||||||
|
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||||||
|
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||||||
|
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
|
||||||
|
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
|
||||||
|
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
|
||||||
|
model: params.model, text: params.text, intent: params.intent }
|
||||||
|
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
|
||||||
|
const correlation = digest([this.deviceId, params.taskId])
|
||||||
|
if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId,
|
||||||
|
runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' })
|
||||||
|
if (!reservation.fresh) {
|
||||||
|
// Recover a terminal event missed while the phone/companion was closed.
|
||||||
|
// Never resubmit even if the bounded replay window no longer has it.
|
||||||
|
const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 })
|
||||||
|
for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params)
|
||||||
|
await this.journal.writes
|
||||||
|
const state = this.tasks.get(correlation).state
|
||||||
|
return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) }
|
||||||
|
}
|
||||||
|
await this.authorize()
|
||||||
|
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true,
|
||||||
|
client_task_id: correlation })
|
||||||
|
await this.journal.accepted(reservation)
|
||||||
|
if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted'
|
||||||
|
return { taskId: params.taskId, state: 'accepted' }
|
||||||
|
}
|
||||||
|
if (message.action === 'approval.respond') {
|
||||||
|
if (this.selected?.storedId !== params.sessionId || !this.approval ||
|
||||||
|
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
|
||||||
|
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
|
||||||
|
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
|
||||||
|
request_id: params.requestId, choice: params.choice, all: false })
|
||||||
|
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
|
||||||
|
this.approval = null
|
||||||
|
return { resolved: true }
|
||||||
|
}
|
||||||
|
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
|
||||||
|
throw new Error('Bu mobil işlem desteklenmiyor.')
|
||||||
|
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
|
||||||
|
throw new Error('Önce konuşmayı seçin.')
|
||||||
|
return super.handle(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { createMobileBackendResolver } from './mobile-backend'
|
||||||
|
|
||||||
|
const local = { mode: 'local', baseUrl: 'http://127.0.0.1:12345', token: 'test-session' }
|
||||||
|
|
||||||
|
it('uses the phone profile even while another desktop profile is awaiting setup', async () => {
|
||||||
|
const ensureBackend = vi.fn(async (profile: string) => {
|
||||||
|
if (profile === 'fusion-frontier') return new Promise<typeof local>(() => {})
|
||||||
|
return local
|
||||||
|
})
|
||||||
|
const resolve = createMobileBackendResolver({
|
||||||
|
isRemote: () => false,
|
||||||
|
runtimeNeedsInstall: () => false,
|
||||||
|
ensureBackend
|
||||||
|
})
|
||||||
|
expect(await resolve('default')).toEqual({ baseUrl: local.baseUrl, token: local.token })
|
||||||
|
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never bootstraps an absent runtime or silently replaces a remote profile with default', async () => {
|
||||||
|
const ensureBackend = vi.fn(async () => local)
|
||||||
|
const resolve = createMobileBackendResolver({
|
||||||
|
isRemote: profile => profile === 'remote',
|
||||||
|
runtimeNeedsInstall: () => true,
|
||||||
|
ensureBackend
|
||||||
|
})
|
||||||
|
await expect(resolve('default')).rejects.toThrow('runtime_not_ready')
|
||||||
|
await expect(resolve('remote')).rejects.toThrow('remote_mode')
|
||||||
|
expect(ensureBackend).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves an explicitly selected local profile and rejects remote-mode changes during resolution', async () => {
|
||||||
|
let remote = false
|
||||||
|
const ensureBackend = vi.fn(async () => local)
|
||||||
|
const resolve = createMobileBackendResolver({
|
||||||
|
isRemote: () => remote,
|
||||||
|
runtimeNeedsInstall: () => false,
|
||||||
|
ensureBackend
|
||||||
|
})
|
||||||
|
await resolve('kodlama')
|
||||||
|
expect(ensureBackend).toHaveBeenCalledExactlyOnceWith('kodlama')
|
||||||
|
ensureBackend.mockImplementationOnce(async () => {
|
||||||
|
remote = true
|
||||||
|
return local
|
||||||
|
})
|
||||||
|
await expect(resolve('kodlama')).rejects.toThrow('remote_mode')
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Mobile routing is explicit and cannot start a first-run installer. */
|
||||||
|
export function createMobileBackendResolver<Connection extends { mode: string; baseUrl: string; token: string }>({
|
||||||
|
isRemote,
|
||||||
|
runtimeNeedsInstall,
|
||||||
|
ensureBackend
|
||||||
|
}: {
|
||||||
|
isRemote: (profile: string) => boolean
|
||||||
|
runtimeNeedsInstall: (profile: string) => boolean
|
||||||
|
ensureBackend: (profile: string) => Promise<Connection>
|
||||||
|
}) {
|
||||||
|
return async (profile: string) => {
|
||||||
|
if (isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
|
||||||
|
if (runtimeNeedsInstall(profile)) throw new Error('aiturk-mobile:runtime_not_ready')
|
||||||
|
const connection = await ensureBackend(profile)
|
||||||
|
if (connection.mode !== 'local' || isRemote(profile)) throw new Error('aiturk-mobile:remote_mode')
|
||||||
|
return { baseUrl: connection.baseUrl, token: connection.token }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the
|
||||||
|
// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process.
|
||||||
|
/* global window, document */
|
||||||
|
import { drawQr } from './mobile/qr.mjs'
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
tr: {
|
||||||
|
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||||
|
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||||
|
toggle: 'Telefon bağlantısını kullan',
|
||||||
|
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||||
|
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||||
|
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||||
|
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||||
|
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||||
|
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||||
|
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||||
|
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||||
|
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||||
|
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||||
|
orCode: 'ya da telefonda şu kodu yazın:',
|
||||||
|
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||||
|
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||||
|
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||||
|
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||||
|
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||||
|
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||||
|
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||||
|
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||||
|
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||||
|
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||||
|
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||||
|
errors: {
|
||||||
|
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||||
|
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||||
|
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||||
|
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||||
|
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||||
|
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||||
|
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||||
|
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||||
|
toggle: 'Use phone connection',
|
||||||
|
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||||
|
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||||
|
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||||
|
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||||
|
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||||
|
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||||
|
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||||
|
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||||
|
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||||
|
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||||
|
orCode: 'or type this code on the phone:',
|
||||||
|
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||||
|
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
|
||||||
|
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||||
|
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||||
|
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||||
|
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||||
|
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||||
|
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||||
|
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||||
|
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||||
|
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||||
|
errors: {
|
||||||
|
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||||
|
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||||
|
not_enabled: 'Turn phone connection on first.',
|
||||||
|
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||||
|
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||||
|
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||||
|
failed: 'That did not work. Try again.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr'
|
||||||
|
const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id)
|
||||||
|
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||||
|
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||||
|
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||||
|
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||||
|
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||||
|
|
||||||
|
let current = null, drawnQr = '', busy = false
|
||||||
|
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||||
|
const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const pairing = current?.pairing
|
||||||
|
if (!pairing) return
|
||||||
|
const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000))
|
||||||
|
$('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0'))
|
||||||
|
if (left === 0) { notice(t.expired); void refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
current = state
|
||||||
|
$('enabled').checked = state.enabled
|
||||||
|
$('remote-box').hidden = !state.remote
|
||||||
|
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||||
|
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||||
|
$('status-box').hidden = !state.enabled || state.remote
|
||||||
|
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||||
|
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||||
|
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||||
|
const noRule = lan.direct && lan.firewall === 'missing'
|
||||||
|
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||||
|
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||||
|
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||||
|
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||||
|
$('lan-disallow').hidden = !lan.direct
|
||||||
|
$('pair-section').hidden = !state.enabled || state.remote
|
||||||
|
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||||
|
if (state.pairing) {
|
||||||
|
if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr }
|
||||||
|
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||||
|
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||||
|
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||||
|
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
|
||||||
|
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
|
||||||
|
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||||
|
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||||
|
tick()
|
||||||
|
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||||
|
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||||
|
$('devices-empty').hidden = state.devices.length > 0
|
||||||
|
$('devices').replaceChildren(...state.devices.map(device => {
|
||||||
|
const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong')
|
||||||
|
const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button')
|
||||||
|
row.className = 'device'; name.textContent = device.name
|
||||||
|
added.textContent = `${t.added}: ${when(device.createdAt)}`
|
||||||
|
seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}`
|
||||||
|
button.textContent = t.remove; button.className = 'danger'
|
||||||
|
button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) })
|
||||||
|
label.append(name, added, seen); row.append(label, button)
|
||||||
|
return row
|
||||||
|
}))
|
||||||
|
$('footer').hidden = !state.enabled || state.remote
|
||||||
|
$('relay-renew').hidden = !relay.ready
|
||||||
|
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(work) {
|
||||||
|
if (busy) return
|
||||||
|
busy = true; document.body.setAttribute('aria-busy', 'true')
|
||||||
|
for (const button of document.querySelectorAll('button,input')) button.disabled = true
|
||||||
|
try { await work() } catch (error) { notice(errorText(error)); await refresh() }
|
||||||
|
finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false }
|
||||||
|
}
|
||||||
|
async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(error)) } }
|
||||||
|
|
||||||
|
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||||
|
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||||
|
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||||
|
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
|
||||||
|
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||||
|
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||||
|
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||||
|
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||||
|
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||||
|
setInterval(tick, 1000)
|
||||||
|
// "Last seen" and the live marker change without an event; keep the list fresh while the window is open.
|
||||||
|
setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000)
|
||||||
|
void refresh()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */
|
||||||
|
.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer }
|
||||||
|
.switch input { width:26px; height:26px; margin:0; flex:none }
|
||||||
|
.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 }
|
||||||
|
.warning p { color:var(--text); margin:4px 0 }
|
||||||
|
.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all }
|
||||||
|
.countdown { text-align:center; color:var(--text); font-weight:600 }
|
||||||
|
.hint { text-align:center }
|
||||||
|
#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px }
|
||||||
|
#pair-live p { text-align:center }
|
||||||
|
.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 }
|
||||||
|
.device strong { color:var(--text); display:block; overflow-wrap:anywhere }
|
||||||
|
.device small { display:block }
|
||||||
|
.device button { flex:none; margin:0 }
|
||||||
|
footer { margin-top:34px }
|
||||||
|
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||||
|
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||||
|
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||||
|
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||||
|
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||||
|
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||||
|
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||||
|
.status-actions button { margin:0; min-height:44px }
|
||||||
|
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||||
|
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||||
|
footer button.link { min-height:44px }
|
||||||
|
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'">
|
||||||
|
<title>AITURK · Telefon bağlantısı</title><link rel="stylesheet" href="mobile/style.css"><link rel="stylesheet" href="mobile-host.css"></head><body><main>
|
||||||
|
<p class="eyebrow" id="t-eyebrow">AITURK · BİLGİSAYAR</p><h1 id="t-title">Telefon bağlantısı</h1>
|
||||||
|
<p id="t-intro"></p>
|
||||||
|
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||||
|
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||||
|
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||||
|
<section id="status-box" hidden>
|
||||||
|
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||||
|
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||||
|
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||||
|
</section>
|
||||||
|
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||||
|
<section id="pair-section" hidden>
|
||||||
|
<button id="pair-begin"></button>
|
||||||
|
<div id="pair-live" hidden>
|
||||||
|
<p id="t-scan"></p>
|
||||||
|
<canvas id="pair-qr" role="img"></canvas>
|
||||||
|
<p id="t-or-code"></p>
|
||||||
|
<p id="pair-code" class="code" aria-live="off"></p>
|
||||||
|
<p id="pair-code-path"></p>
|
||||||
|
<button id="pair-republish" class="link" hidden></button>
|
||||||
|
<p id="pair-countdown" class="countdown"></p>
|
||||||
|
<p id="pair-security" class="security"></p>
|
||||||
|
<p id="t-same-wifi" class="hint"></p>
|
||||||
|
<button id="pair-cancel" class="secondary"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="devices-section" hidden>
|
||||||
|
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||||
|
</section>
|
||||||
|
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||||
|
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// "Telefon bağlantısı" window: a thin IPC shell around the controller built in
|
||||||
|
// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the
|
||||||
|
// main process for the whole app run — closing this window only cancels a
|
||||||
|
// pairing in progress; paired phones stay connected.
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
|
|
||||||
|
const CODED = /^aiturk-mobile:[a-z_]+$/
|
||||||
|
|
||||||
|
export function createMobileWindow({ assets, link, show = true }) {
|
||||||
|
const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı',
|
||||||
|
webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true,
|
||||||
|
nodeIntegration: false, sandbox: true } })
|
||||||
|
window.setMenu(null)
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
window.webContents.on('will-navigate', event => event.preventDefault())
|
||||||
|
const actions = {
|
||||||
|
state: () => link.state(),
|
||||||
|
enable: () => link.enable(),
|
||||||
|
disable: () => link.disable(),
|
||||||
|
'pair.begin': () => link.pairBegin(),
|
||||||
|
'pair.cancel': () => link.pairCancel(),
|
||||||
|
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||||
|
'pair.republish': () => link.pairRepublish(),
|
||||||
|
devices: () => link.devices(),
|
||||||
|
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||||
|
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||||
|
'lan.allow': () => link.lanAllow(),
|
||||||
|
'lan.disallow': () => link.lanDisallow(),
|
||||||
|
'firewall.allow': () => link.lanAllow(),
|
||||||
|
'firewall.retry': () => link.lanAllow(),
|
||||||
|
'relay.renew': () => link.relayRenew(),
|
||||||
|
'firewall.remove': () => link.firewallRemove(),
|
||||||
|
}
|
||||||
|
const handler = async (event, action, params) => {
|
||||||
|
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden')
|
||||||
|
if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported')
|
||||||
|
try { return await actions[action](params) } catch (error) {
|
||||||
|
// Only fixed codes cross to the renderer; never upstream error text.
|
||||||
|
throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) })
|
||||||
|
ipcMain.handle('aiturk-mobile', handler)
|
||||||
|
window.on('closed', () => {
|
||||||
|
unsubscribe(); ipcMain.removeHandler('aiturk-mobile')
|
||||||
|
// The code on screen is gone with the window; do not leave pairing open behind it.
|
||||||
|
void link.pairCancel().catch(() => {})
|
||||||
|
})
|
||||||
|
void window.loadFile(join(assets, 'mobile-host.html'))
|
||||||
|
return window
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed
|
||||||
|
* certificate of the "Telefon bağlantısı" LAN proxy and its vault storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSelfSignedCertificate,
|
||||||
|
fingerprintOfPem,
|
||||||
|
isUsableIdentity,
|
||||||
|
loadOrCreateLanIdentity
|
||||||
|
} from './mobile-lan-cert'
|
||||||
|
|
||||||
|
function memoryVault(initial: Record<string, any> = {}) {
|
||||||
|
const state = { value: structuredClone(initial), saves: 0 }
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
load: async () => structuredClone(state.value),
|
||||||
|
save: async (value: Record<string, any>) => {
|
||||||
|
state.value = structuredClone(value)
|
||||||
|
state.saves++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSelfSignedCertificate', () => {
|
||||||
|
it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => {
|
||||||
|
const identity = createSelfSignedCertificate()
|
||||||
|
const x509 = new crypto.X509Certificate(identity.cert)
|
||||||
|
|
||||||
|
expect(x509.verify(x509.publicKey)).toBe(true)
|
||||||
|
expect(x509.publicKey.asymmetricKeyType).toBe('ec')
|
||||||
|
expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||||
|
expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true)
|
||||||
|
expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3)
|
||||||
|
expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => {
|
||||||
|
const identity = createSelfSignedCertificate()
|
||||||
|
const der = new crypto.X509Certificate(identity.cert).raw
|
||||||
|
|
||||||
|
expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||||
|
expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url'))
|
||||||
|
expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint)
|
||||||
|
expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a validity that UTCTime cannot carry', () => {
|
||||||
|
expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow(
|
||||||
|
'mobile-lan-cert:validity-out-of-range'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadOrCreateLanIdentity', () => {
|
||||||
|
it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => {
|
||||||
|
const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } })
|
||||||
|
const first = await loadOrCreateLanIdentity(vault)
|
||||||
|
|
||||||
|
expect(first.created).toBe(true)
|
||||||
|
expect(vault.state.value.accountToken).toBe('old-field')
|
||||||
|
expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' })
|
||||||
|
expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY')
|
||||||
|
|
||||||
|
const second = await loadOrCreateLanIdentity(vault)
|
||||||
|
|
||||||
|
expect(second.created).toBe(false)
|
||||||
|
expect(second.fingerprint).toBe(first.fingerprint)
|
||||||
|
expect(vault.state.saves).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a corrupt, mismatched or nearly expired identity', async () => {
|
||||||
|
const good = createSelfSignedCertificate()
|
||||||
|
const other = createSelfSignedCertificate()
|
||||||
|
|
||||||
|
expect(isUsableIdentity(good)).toBe(true)
|
||||||
|
expect(isUsableIdentity({ ...good, key: other.key })).toBe(false)
|
||||||
|
expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false)
|
||||||
|
expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false)
|
||||||
|
expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false)
|
||||||
|
|
||||||
|
const vault = memoryVault({ lanTls: { ...good, key: other.key } })
|
||||||
|
const renewed = await loadOrCreateLanIdentity(vault)
|
||||||
|
|
||||||
|
expect(renewed.created).toBe(true)
|
||||||
|
expect(renewed.fingerprint).not.toBe(good.fingerprint)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a vault failure instead of running without a stored key', async () => {
|
||||||
|
const vault = {
|
||||||
|
load: async () => {
|
||||||
|
throw new Error('vault closed')
|
||||||
|
},
|
||||||
|
save: async () => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* mobile-lan-cert.ts
|
||||||
|
*
|
||||||
|
* Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy
|
||||||
|
* (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built
|
||||||
|
* by hand and signed with node:crypto.
|
||||||
|
*
|
||||||
|
* The phone never trusts a CA for this server. It pins the certificate by
|
||||||
|
* `fp` = SHA-256 of the certificate's DER bytes, base64url without padding
|
||||||
|
* (43 characters), which it learns from the pairing QR / rendezvous record.
|
||||||
|
*
|
||||||
|
* The private key lives only in the injected vault (MobileVault → Electron
|
||||||
|
* safeStorage → Windows DPAPI). Existing vault fields are preserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
export const MOBILE_LAN_CERT_DAYS = 3650
|
||||||
|
/** Regenerate when less than this is left; the phone must then pair again. */
|
||||||
|
const RENEW_BEFORE_MS = 30 * 24 * 3600e3
|
||||||
|
const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/
|
||||||
|
|
||||||
|
export interface MobileLanIdentity {
|
||||||
|
/** PEM certificate. */
|
||||||
|
cert: string
|
||||||
|
/** PEM PKCS#8 private key. Never leaves the main process. */
|
||||||
|
key: string
|
||||||
|
/** SHA-256 of the DER certificate, base64url, 43 characters. */
|
||||||
|
fingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileLanVault {
|
||||||
|
load: () => Promise<Record<string, any>>
|
||||||
|
save: (value: Record<string, any>) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function der(tag: number, ...parts: Buffer[]): Buffer {
|
||||||
|
const body = Buffer.concat(parts)
|
||||||
|
let length: Buffer
|
||||||
|
|
||||||
|
if (body.length < 128) {
|
||||||
|
length = Buffer.from([body.length])
|
||||||
|
} else {
|
||||||
|
const bytes: number[] = []
|
||||||
|
|
||||||
|
for (let n = body.length; n > 0; n >>= 8) {
|
||||||
|
bytes.unshift(n & 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
length = Buffer.from([0x80 | bytes.length, ...bytes])
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat([Buffer.from([tag]), length, body])
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = (...parts: Buffer[]) => der(0x30, ...parts)
|
||||||
|
const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex'))
|
||||||
|
const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value)
|
||||||
|
|
||||||
|
const utcTime = (date: Date) =>
|
||||||
|
der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii'))
|
||||||
|
|
||||||
|
const ECDSA_SHA256 = seq(oid('2a8648ce3d040302'))
|
||||||
|
const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8')))))
|
||||||
|
|
||||||
|
export function fingerprintOfDer(certDer: Uint8Array): string {
|
||||||
|
return crypto.createHash('sha256').update(certDer).digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */
|
||||||
|
export function fingerprintOfPem(certPem: string): string {
|
||||||
|
return fingerprintOfDer(new crypto.X509Certificate(certPem).raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSelfSignedCertificate(
|
||||||
|
options: { commonName?: string; days?: number; now?: Date } = {}
|
||||||
|
): MobileLanIdentity {
|
||||||
|
const commonName = options.commonName ?? 'AITURK IDE'
|
||||||
|
const days = options.days ?? MOBILE_LAN_CERT_DAYS
|
||||||
|
const now = options.now ?? new Date()
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
|
||||||
|
const serial = crypto.randomBytes(16)
|
||||||
|
|
||||||
|
serial[0] = (serial[0] & 0x7f) | 0x01
|
||||||
|
|
||||||
|
const notBefore = new Date(now.getTime() - 24 * 3600e3)
|
||||||
|
const notAfter = new Date(now.getTime() + days * 24 * 3600e3)
|
||||||
|
|
||||||
|
if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) {
|
||||||
|
// UTCTime cannot carry these years; refuse rather than emit a wrong date.
|
||||||
|
throw new Error('mobile-lan-cert:validity-out-of-range')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbs = seq(
|
||||||
|
der(0xa0, int(Buffer.from([2]))), // v3
|
||||||
|
int(serial),
|
||||||
|
ECDSA_SHA256,
|
||||||
|
name(commonName),
|
||||||
|
seq(utcTime(notBefore), utcTime(notAfter)),
|
||||||
|
name(commonName),
|
||||||
|
publicKey.export({ type: 'spki', format: 'der' }) as Buffer
|
||||||
|
)
|
||||||
|
|
||||||
|
const signature = crypto.sign('sha256', tbs, privateKey)
|
||||||
|
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature))
|
||||||
|
const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`,
|
||||||
|
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
||||||
|
fingerprint: fingerprintOfDer(certDer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the stored pair is a usable, unexpired, matching identity. */
|
||||||
|
export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity {
|
||||||
|
const row = value as MobileLanIdentity | null
|
||||||
|
|
||||||
|
if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const x509 = new crypto.X509Certificate(row.cert)
|
||||||
|
|
||||||
|
return (
|
||||||
|
fingerprintOfDer(x509.raw) === row.fingerprint &&
|
||||||
|
x509.checkPrivateKey(crypto.createPrivateKey(row.key)) &&
|
||||||
|
new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the LAN identity from the vault, or create and store one. Stored under
|
||||||
|
* the vault's `lanTls` field; every other field is written back untouched.
|
||||||
|
* `created` tells the caller that previously paired phones no longer match.
|
||||||
|
*/
|
||||||
|
export async function loadOrCreateLanIdentity(
|
||||||
|
vault: MobileLanVault,
|
||||||
|
options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {}
|
||||||
|
): Promise<MobileLanIdentity & { created: boolean }> {
|
||||||
|
const now = options.now ? options.now() : new Date()
|
||||||
|
const secrets = (await vault.load()) ?? {}
|
||||||
|
|
||||||
|
if (isUsableIdentity(secrets.lanTls, now)) {
|
||||||
|
const { cert, key, fingerprint } = secrets.lanTls
|
||||||
|
|
||||||
|
return { cert, key, fingerprint, created: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = (options.create ?? createSelfSignedCertificate)({ now })
|
||||||
|
|
||||||
|
await vault.save({ ...secrets, lanTls: identity })
|
||||||
|
|
||||||
|
return { ...identity, created: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/** Opt-in acceptance against a running local IDE backend. No credentials on disk. */
|
||||||
|
import https from 'node:https'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import type { PeerCertificate } from 'node:tls'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import { WebSocket } from 'ws'
|
||||||
|
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
||||||
|
import { createLanProxy } from './mobile-lan-proxy'
|
||||||
|
|
||||||
|
it.skipIf(!process.env.AITURK_TEST_BACKEND_PORT)(
|
||||||
|
'real backend: pinned TLS, screens, cron lifecycle and chat socket',
|
||||||
|
async () => {
|
||||||
|
const port = Number(process.env.AITURK_TEST_BACKEND_PORT)
|
||||||
|
expect(Number.isInteger(port) && port > 0 && port < 65536).toBe(true)
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
const root = await (await fetch(baseUrl)).text()
|
||||||
|
const session = root.match(/window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/)?.[1]
|
||||||
|
expect(Boolean(session)).toBe(true)
|
||||||
|
const identity = createSelfSignedCertificate()
|
||||||
|
const checkServerIdentity = (_host: string, cert: PeerCertificate) =>
|
||||||
|
crypto.createHash('sha256').update(cert.raw).digest('base64url') === identity.fingerprint
|
||||||
|
? undefined
|
||||||
|
: new Error('Certificate pin mismatch')
|
||||||
|
const proxy = createLanProxy({
|
||||||
|
tls: identity,
|
||||||
|
version: 'acceptance',
|
||||||
|
pcName: 'acceptance',
|
||||||
|
resolveBackend: async profile => {
|
||||||
|
if (profile !== 'default') throw new Error('Acceptance may only use the default profile')
|
||||||
|
return { baseUrl, token: session! }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const proxyPort = await proxy.listen(0, '127.0.0.1')
|
||||||
|
let deviceToken = ''
|
||||||
|
let createdId: string | undefined
|
||||||
|
const call = (path: string, method = 'GET', body?: unknown): Promise<{ status: number; data: any }> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: proxyPort,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
ca: identity.cert,
|
||||||
|
checkServerIdentity,
|
||||||
|
headers: {
|
||||||
|
...(deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {}),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
res => {
|
||||||
|
let value = ''
|
||||||
|
res.on('data', chunk => (value += chunk))
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve({ status: res.statusCode!, data: value ? JSON.parse(value) : null })
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Non-JSON response: ${path}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.setTimeout(60000, () => req.destroy(new Error(`Timeout: ${path}`)))
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end(body === undefined ? undefined : JSON.stringify(body))
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const pair = await call('/aiturk/pair/claim', 'POST', {
|
||||||
|
code: proxy.beginPairing().code,
|
||||||
|
device_name: 'Acceptance ephemeral'
|
||||||
|
})
|
||||||
|
expect(pair.status).toBe(200)
|
||||||
|
deviceToken = pair.data.token
|
||||||
|
for (const endpoint of [
|
||||||
|
'health',
|
||||||
|
'profiles',
|
||||||
|
'sessions',
|
||||||
|
'cron/jobs',
|
||||||
|
'cron/blueprints',
|
||||||
|
'cron/delivery-targets',
|
||||||
|
'model/info',
|
||||||
|
'skills',
|
||||||
|
'tools/toolsets',
|
||||||
|
'mcp/servers',
|
||||||
|
'plugins/kanban/boards'
|
||||||
|
]) {
|
||||||
|
const response = await call(`/api/${endpoint}?profile=default`)
|
||||||
|
expect(response.status, endpoint).toBe(200)
|
||||||
|
}
|
||||||
|
const created = await call('/api/cron/jobs?profile=default', 'POST', {
|
||||||
|
name: `AITURK mobile acceptance ${Date.now()}`,
|
||||||
|
schedule: '0 0 1 1 *',
|
||||||
|
prompt: 'Temporary acceptance record; deleted immediately without execution.',
|
||||||
|
deliver: 'local'
|
||||||
|
})
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
createdId = created.data.id
|
||||||
|
expect(typeof createdId).toBe('string')
|
||||||
|
const route = `/api/cron/jobs/${encodeURIComponent(createdId!)}`
|
||||||
|
expect((await call(`${route}/pause?profile=default`, 'POST')).status).toBe(200)
|
||||||
|
expect(
|
||||||
|
(await call(`${route}?profile=default`, 'PUT', { updates: { name: 'AITURK acceptance updated' } })).status
|
||||||
|
).toBe(200)
|
||||||
|
expect((await call(`${route}/resume?profile=default`, 'POST')).status).toBe(200)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`wss://127.0.0.1:${proxyPort}/api/ws?profile=default`, {
|
||||||
|
ca: identity.cert,
|
||||||
|
checkServerIdentity,
|
||||||
|
headers: { Authorization: `Bearer ${deviceToken}` }
|
||||||
|
})
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
ws.terminate()
|
||||||
|
reject(new Error('Chat ping timed out'))
|
||||||
|
}, 15000)
|
||||||
|
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 123, method: 'gateway.ping', params: {} })))
|
||||||
|
ws.on('message', raw => {
|
||||||
|
const message = JSON.parse(String(raw))
|
||||||
|
if (message.id === 123) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
ws.close()
|
||||||
|
message.error ? reject(new Error('Chat ping rejected')) : resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws.on('error', error => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (createdId) {
|
||||||
|
const route = `/api/cron/jobs/${encodeURIComponent(createdId)}?profile=default`
|
||||||
|
expect((await call(route, 'DELETE')).status).toBe(200)
|
||||||
|
expect((await call(route)).status).toBe(404)
|
||||||
|
}
|
||||||
|
await proxy.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
180000
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
contextBridge.exposeInMainWorld('mobileHost', {
|
||||||
|
call: (action, params) => ipcRenderer.invoke('aiturk-mobile', action, params),
|
||||||
|
onEvent: callback => ipcRenderer.on('aiturk-mobile-event', (_event, value) => callback(value)),
|
||||||
|
})
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
/**
|
||||||
|
* mobile-relay-client.ts
|
||||||
|
*
|
||||||
|
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||||
|
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||||
|
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||||
|
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||||
|
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||||
|
*
|
||||||
|
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||||
|
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||||
|
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||||
|
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||||
|
*
|
||||||
|
* Security contract (do not relax):
|
||||||
|
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||||
|
* controller keeps it in the safeStorage vault). It is sent in the
|
||||||
|
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||||
|
* log line, a status object or an error message.
|
||||||
|
* - Log lines are status changes only.
|
||||||
|
* - Errors are fixed codes.
|
||||||
|
*
|
||||||
|
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||||
|
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||||
|
* relay on 127.0.0.1 and a fake clock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||||
|
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||||
|
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||||
|
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||||
|
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||||
|
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||||
|
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||||
|
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||||
|
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||||
|
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||||
|
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||||
|
export const RELAY_SILENCE_MS = 75_000
|
||||||
|
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||||
|
export const RELAY_MAX_STREAMS = 16
|
||||||
|
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||||
|
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||||
|
const BUFFER_POLL_MS = 25
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||||
|
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||||
|
|
||||||
|
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||||
|
|
||||||
|
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||||
|
export function isRelayId(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && UUID_RE.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayCredentials {
|
||||||
|
relayId: string
|
||||||
|
hostToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||||
|
const row = value as null | RelayCredentials
|
||||||
|
|
||||||
|
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||||
|
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||||
|
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||||
|
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||||
|
|
||||||
|
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayFetchResponse {
|
||||||
|
status: number
|
||||||
|
json: () => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||||
|
|
||||||
|
export type RelayFailureCode =
|
||||||
|
| 'register_failed'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'vault_unavailable'
|
||||||
|
| 'connect_failed'
|
||||||
|
| 'token_rejected'
|
||||||
|
| 'replaced'
|
||||||
|
| 'busy'
|
||||||
|
| 'timeout'
|
||||||
|
|
||||||
|
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||||
|
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||||
|
|
||||||
|
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
|
||||||
|
export function isTimeoutError(error: unknown): boolean {
|
||||||
|
const name = (error as null | { name?: unknown })?.name
|
||||||
|
|
||||||
|
return name === 'TimeoutError' || name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkRetryOptions {
|
||||||
|
/** Injected wait (tests: fake clock). Default: a real timer. */
|
||||||
|
sleep?: (ms: number) => Promise<void>
|
||||||
|
retryDelayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /sessions`; a fixed failure code instead of any upstream text.
|
||||||
|
*
|
||||||
|
* A process that sat idle for a long time can fail its first request on a dead
|
||||||
|
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
|
||||||
|
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
|
||||||
|
*/
|
||||||
|
export async function registerRelaySession(
|
||||||
|
fetcher: RelayFetch,
|
||||||
|
options: NetworkRetryOptions = {}
|
||||||
|
): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||||
|
const sleep = options.sleep ?? realSleep
|
||||||
|
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
let response: RelayFetchResponse
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= 2 || isTimeoutError(error)) {
|
||||||
|
return { error: 'register_failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (response.status !== 201) {
|
||||||
|
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||||
|
} catch {
|
||||||
|
return { error: 'register_failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the client needs from the outside ──────────────────────────────────
|
||||||
|
|
||||||
|
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||||
|
export interface RelaySocket {
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||||
|
/** A string is a text frame, a Buffer a binary frame. */
|
||||||
|
send: (data: Buffer | string) => unknown
|
||||||
|
close: (code?: number) => unknown
|
||||||
|
terminate?: () => unknown
|
||||||
|
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||||
|
pause?: () => unknown
|
||||||
|
resume?: () => unknown
|
||||||
|
readonly bufferedAmount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||||
|
|
||||||
|
export interface RelayWebSocketCtor {
|
||||||
|
new (url: string, options?: Record<string, unknown>): any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||||
|
export interface RelayLocalSocket {
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||||
|
write: (data: Buffer) => boolean
|
||||||
|
destroy: () => unknown
|
||||||
|
pause: () => unknown
|
||||||
|
resume: () => unknown
|
||||||
|
readonly writableLength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = the proxy is not listening right now. */
|
||||||
|
export type RelayDial = () => null | RelayLocalSocket
|
||||||
|
|
||||||
|
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||||
|
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||||
|
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||||
|
|
||||||
|
frame.writeUInt32BE(id, 0)
|
||||||
|
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||||
|
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||||
|
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||||
|
const parts: Buffer[] = []
|
||||||
|
|
||||||
|
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||||
|
parts.push(chunk.subarray(offset, offset + size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||||
|
|
||||||
|
// ── The client ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||||
|
|
||||||
|
export interface RelayState {
|
||||||
|
status: RelayStatus
|
||||||
|
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||||
|
code: null | RelayFailureCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayCredentialStore {
|
||||||
|
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||||
|
load: () => Promise<null | RelayCredentials>
|
||||||
|
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||||
|
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||||
|
clear: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayClientDeps {
|
||||||
|
connect: RelayConnect
|
||||||
|
fetch: RelayFetch
|
||||||
|
dial: RelayDial
|
||||||
|
credentials: RelayCredentialStore
|
||||||
|
setTimer?: (fn: () => void, ms: number) => unknown
|
||||||
|
clearTimer?: (handle: unknown) => void
|
||||||
|
/** Status changes only. */
|
||||||
|
log?: (line: string) => void
|
||||||
|
onState?: (state: RelayState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stream {
|
||||||
|
local: RelayLocalSocket
|
||||||
|
paused: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRelayClient(deps: RelayClientDeps) {
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||||
|
|
||||||
|
let state: RelayState = { status: 'off', code: null }
|
||||||
|
let wanted = false
|
||||||
|
let generation = 0
|
||||||
|
let attempt = 0
|
||||||
|
let socket: null | RelaySocket = null
|
||||||
|
let credentials: null | RelayCredentials = null
|
||||||
|
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||||
|
let rejected = false
|
||||||
|
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||||
|
let retryTimer: unknown = null
|
||||||
|
let silenceTimer: unknown = null
|
||||||
|
const streams = new Map<number, Stream>()
|
||||||
|
/** Same (injectable) clock as every other wait of this client. */
|
||||||
|
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||||
|
|
||||||
|
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||||
|
if (state.status === status && state.code === code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state = { status, code }
|
||||||
|
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.onState?.(state)
|
||||||
|
} catch {
|
||||||
|
// a broken listener must not break the relay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (retryTimer !== null) {
|
||||||
|
clearTimer(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silenceTimer !== null) {
|
||||||
|
clearTimer(silenceTimer)
|
||||||
|
silenceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored registration, or a new one. One request at a time. */
|
||||||
|
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||||
|
if (credentials) {
|
||||||
|
return Promise.resolve(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||||
|
let stored: null | RelayCredentials
|
||||||
|
|
||||||
|
try {
|
||||||
|
stored = await deps.credentials.load()
|
||||||
|
} catch {
|
||||||
|
return { error: 'vault_unavailable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRelayCredentials(stored)) {
|
||||||
|
credentials = stored
|
||||||
|
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||||
|
|
||||||
|
if ('error' in fresh) {
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.credentials.save(fresh, { reissued: rejected })
|
||||||
|
} catch {
|
||||||
|
return { error: 'vault_unavailable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected = false
|
||||||
|
credentials = fresh
|
||||||
|
|
||||||
|
return fresh
|
||||||
|
})().finally(() => {
|
||||||
|
registering = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return registering
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropStreams() {
|
||||||
|
for (const stream of streams.values()) {
|
||||||
|
stream.local.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry(code: RelayFailureCode, delay?: number) {
|
||||||
|
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
setState('failed', code)
|
||||||
|
retryTimer = setTimer(() => {
|
||||||
|
retryTimer = null
|
||||||
|
void connect()
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!wanted || socket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mine = ++generation
|
||||||
|
|
||||||
|
clearTimers()
|
||||||
|
setState('connecting')
|
||||||
|
|
||||||
|
const current = await register()
|
||||||
|
|
||||||
|
if (!wanted || mine !== generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('error' in current) {
|
||||||
|
retry(current.error)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws: RelaySocket
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||||
|
} catch {
|
||||||
|
retry('connect_failed')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = ws
|
||||||
|
|
||||||
|
const send = (data: Buffer | string) => {
|
||||||
|
if (socket === ws) {
|
||||||
|
try {
|
||||||
|
ws.send(data)
|
||||||
|
} catch {
|
||||||
|
// the close event follows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||||
|
|
||||||
|
const watch = () => {
|
||||||
|
if (silenceTimer !== null) {
|
||||||
|
clearTimer(silenceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
silenceTimer = setTimer(() => {
|
||||||
|
silenceTimer = null
|
||||||
|
|
||||||
|
if (socket === ws) {
|
||||||
|
try {
|
||||||
|
;(ws.terminate ?? ws.close).call(ws)
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
down(1006, 'timeout')
|
||||||
|
}
|
||||||
|
}, RELAY_SILENCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const down = (code: number, reason?: RelayFailureCode) => {
|
||||||
|
if (socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = null
|
||||||
|
clearTimers()
|
||||||
|
dropStreams()
|
||||||
|
|
||||||
|
if (!wanted) {
|
||||||
|
setState('off')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||||
|
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||||
|
if (code === 4401 || code === 4404) {
|
||||||
|
credentials = null
|
||||||
|
rejected = true
|
||||||
|
void deps.credentials
|
||||||
|
.clear()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => {
|
||||||
|
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||||
|
retry('token_rejected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 4409) {
|
||||||
|
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||||
|
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4429: the relay service is full; the normal growing delay applies.
|
||||||
|
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||||
|
const stalled = new Set<Stream>()
|
||||||
|
|
||||||
|
const unstall = (stream: Stream) => {
|
||||||
|
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||||
|
ws.resume?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||||
|
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||||
|
if (streams.get(id) !== stream) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.delete(id)
|
||||||
|
unstall(stream)
|
||||||
|
stream.local.destroy()
|
||||||
|
|
||||||
|
if (tell) {
|
||||||
|
control({ t: 'close', id, code })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStream = (id: number) => {
|
||||||
|
if (streams.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||||
|
return control({ t: 'close', id, code: 4429 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let local: null | RelayLocalSocket
|
||||||
|
|
||||||
|
try {
|
||||||
|
local = deps.dial()
|
||||||
|
} catch {
|
||||||
|
local = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!local) {
|
||||||
|
return control({ t: 'close', id, code: 4503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream: Stream = { local, paused: false }
|
||||||
|
let failed = false
|
||||||
|
|
||||||
|
const live = () => streams.get(id) === stream
|
||||||
|
|
||||||
|
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||||
|
const drain = () => {
|
||||||
|
if (!live() || socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||||
|
setTimer(drain, BUFFER_POLL_MS)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.paused = false
|
||||||
|
local.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.set(id, stream)
|
||||||
|
local.on('connect', () => {
|
||||||
|
if (live()) {
|
||||||
|
control({ t: 'opened', id })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
local.on('data', (chunk: Buffer) => {
|
||||||
|
if (!live()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||||
|
for (const part of splitRelayChunks(chunk)) {
|
||||||
|
send(encodeRelayFrame(id, part))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||||
|
stream.paused = true
|
||||||
|
local.pause()
|
||||||
|
setTimer(drain, BUFFER_POLL_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
local.on('drain', () => unstall(stream))
|
||||||
|
local.on('error', () => {
|
||||||
|
failed = true
|
||||||
|
})
|
||||||
|
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
if (socket === ws) {
|
||||||
|
attempt = 0
|
||||||
|
setState('connected')
|
||||||
|
watch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||||
|
ws.on('error', () => down(1006))
|
||||||
|
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||||
|
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||||
|
const status = Number(res?.statusCode)
|
||||||
|
|
||||||
|
res?.resume?.()
|
||||||
|
req?.destroy?.()
|
||||||
|
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||||
|
})
|
||||||
|
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||||
|
if (socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watch()
|
||||||
|
|
||||||
|
if (isBinary) {
|
||||||
|
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||||
|
|
||||||
|
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = frame.readUInt32BE(0)
|
||||||
|
const stream = streams.get(id)
|
||||||
|
|
||||||
|
// Late or unknown ids are ignored.
|
||||||
|
if (!stream) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||||
|
|
||||||
|
const pending = stream.local.writableLength ?? 0
|
||||||
|
|
||||||
|
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||||
|
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||||
|
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||||
|
endStream(id, stream, 4408, true)
|
||||||
|
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||||
|
stalled.add(stream)
|
||||||
|
ws.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'ping') {
|
||||||
|
return control({ t: 'pong' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validStreamId(message.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'open') {
|
||||||
|
return openStream(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'close') {
|
||||||
|
const stream = streams.get(message.id)
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
endStream(message.id, stream, 1000, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
state: (): RelayState => state,
|
||||||
|
|
||||||
|
/** Open phone streams right now. */
|
||||||
|
streams: (): number => streams.size,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||||
|
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||||
|
*/
|
||||||
|
async ensureRegistered(): Promise<null | string> {
|
||||||
|
const current = await register()
|
||||||
|
|
||||||
|
return 'error' in current ? null : current.relayId
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||||
|
* only once the new one is stored; false = nothing changed.
|
||||||
|
*/
|
||||||
|
async renew(): Promise<boolean> {
|
||||||
|
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||||
|
|
||||||
|
if ('error' in fresh) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.credentials.save(fresh, { reissued: true })
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = fresh
|
||||||
|
rejected = false
|
||||||
|
|
||||||
|
if (wanted) {
|
||||||
|
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||||
|
api.stop()
|
||||||
|
api.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (wanted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted = true
|
||||||
|
attempt = 0
|
||||||
|
void connect()
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
wanted = false
|
||||||
|
generation++
|
||||||
|
clearTimers()
|
||||||
|
|
||||||
|
const ws = socket
|
||||||
|
|
||||||
|
socket = null
|
||||||
|
dropStreams()
|
||||||
|
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.close(1000)
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('off')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { ASSISTANT_INSTRUCTION, VOICE_TOOLS } from '../../mobile/src/behavior.mjs'
|
||||||
|
|
||||||
|
export class MobileVault {
|
||||||
|
constructor(directory, safeStorage) { this.directory = directory; this.safeStorage = safeStorage }
|
||||||
|
async load() {
|
||||||
|
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
|
||||||
|
try {
|
||||||
|
const data = await readFile(join(this.directory, 'mobile-secrets.bin'))
|
||||||
|
return JSON.parse(this.safeStorage.decryptString(data))
|
||||||
|
} catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Mobil anahtar kasası açılamadı.') }
|
||||||
|
}
|
||||||
|
async save(value) {
|
||||||
|
if (!this.safeStorage.isEncryptionAvailable()) throw new Error('Windows güvenli anahtar kasası kullanılamıyor.')
|
||||||
|
const encrypted = this.safeStorage.encryptString(JSON.stringify(value))
|
||||||
|
await mkdir(this.directory, { recursive: true })
|
||||||
|
const file = join(this.directory, 'mobile-secrets.bin')
|
||||||
|
await writeFile(file + '.tmp', encrypted, { mode: 0o600 })
|
||||||
|
await rename(file + '.tmp', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIVE_MODEL = 'gemini-3.1-flash-live-preview'
|
||||||
|
export class LiveTokenIssuer {
|
||||||
|
constructor(vault, fetcher = fetch, now = Date.now) { this.vault = vault; this.fetcher = fetcher; this.now = now; this.last = new Map() }
|
||||||
|
async issue(deviceId) {
|
||||||
|
const now = this.now()
|
||||||
|
if (now - (this.last.get(deviceId) ?? -Infinity) < 30000) throw new Error('Yeni ses bağlantısı için 30 saniye bekleyin.')
|
||||||
|
this.last.set(deviceId, now)
|
||||||
|
const secrets = await this.vault.load()
|
||||||
|
if (!secrets.googleKey) throw new Error('Önce kendi Google AI Studio anahtarınızı ekleyin.')
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await this.fetcher('https://generativelanguage.googleapis.com/v1beta/auth_tokens', {
|
||||||
|
method: 'POST', headers: { 'x-goog-api-key': secrets.googleKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uses: 1, expireTime: new Date(now + 10 * 60000).toISOString(),
|
||||||
|
newSessionExpireTime: new Date(now + 60000).toISOString(),
|
||||||
|
bidiGenerateContentSetup: { model: `models/${LIVE_MODEL}`,
|
||||||
|
generationConfig: { responseModalities: ['AUDIO'] },
|
||||||
|
systemInstruction: { parts: [{ text: ASSISTANT_INSTRUCTION }] },
|
||||||
|
tools: [{ functionDeclarations: VOICE_TOOLS }],
|
||||||
|
inputAudioTranscription: {}, outputAudioTranscription: {} } }),
|
||||||
|
redirect: 'error', signal: AbortSignal.timeout(20000) })
|
||||||
|
} catch { throw new Error('Google ses erişimine ulaşılamadı.') }
|
||||||
|
if (!response.ok) throw new Error(response.status === 429 ? 'Google kotası veya istek sınırı doldu. AI Studio üzerinden kontrol edin.'
|
||||||
|
: response.status === 401 || response.status === 403 ? 'Google anahtarı veya ses modeli erişimi geçersiz.' : 'Google ses erişimi oluşturulamadı.')
|
||||||
|
const data = await response.json()
|
||||||
|
if (typeof data.name !== 'string' || !data.name.startsWith('auth_tokens/')) throw new Error('Google geçerli kısa ömürlü erişim döndürmedi.')
|
||||||
|
return { token: data.name, model: LIVE_MODEL, expiresAt: new Date(now + 10 * 60000).toISOString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const hudNativeDrag = hudWindowing?.nativeDrag === true
|
|||||||
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
const launchFlags = ipcRenderer.sendSync('hermes:launch-flags')
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||||
|
codexLink: { control: request => ipcRenderer.invoke('aiturk:codex-link:control', request) },
|
||||||
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
setUiLanguage: (locale: string) => ipcRenderer.send('hermes:ui-language', locale),
|
||||||
glassSupported: translucencySupport?.glass === true,
|
glassSupported: translucencySupport?.glass === true,
|
||||||
translucencySupported: translucencySupport?.translucency === true,
|
translucencySupported: translucencySupport?.translucency === true,
|
||||||
@@ -278,6 +279,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
||||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||||
|
aiturkLogin: {
|
||||||
|
// One-click TurkServis sign-in. API keys never cross this bridge: main
|
||||||
|
// saves the provider and hands the CLI its key (electron/aiturk-login.ts).
|
||||||
|
start: scope => ipcRenderer.invoke('aiturk:login:start', scope),
|
||||||
|
poll: () => ipcRenderer.invoke('aiturk:login:poll'),
|
||||||
|
cancel: () => ipcRenderer.invoke('aiturk:login:cancel'),
|
||||||
|
overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli')
|
||||||
|
},
|
||||||
|
aiturkMobile: {
|
||||||
|
// Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs).
|
||||||
|
open: () => ipcRenderer.invoke('aiturk:mobile:open')
|
||||||
|
},
|
||||||
mcpOauth: {
|
mcpOauth: {
|
||||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||||
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
|
||||||
|
|||||||
@@ -173,3 +173,21 @@ test('reset rejects with a typed error and never enters either backend', async (
|
|||||||
assert.equal(options.connectRemote.mock.calls.length, 0)
|
assert.equal(options.connectRemote.mock.calls.length, 0)
|
||||||
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a sibling runtime completion re-resolves the primary profile without re-running the stale installer', async () => {
|
||||||
|
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||||
|
const installed = { kind: 'python', activeRoot: bootstrapBackend.activeRoot, args: ['--profile', 'fusion-frontier'] }
|
||||||
|
const prepareLocalBackend = vi.fn().mockResolvedValueOnce(bootstrapBackend).mockResolvedValue(installed)
|
||||||
|
const options = startupOptions({ waitForDecision: gate.wait, prepareLocalBackend })
|
||||||
|
const pending = runPrimaryBackendStartup(options)
|
||||||
|
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
|
||||||
|
gate.runtimeAvailable(bootstrapBackend.activeRoot)
|
||||||
|
await pending
|
||||||
|
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[installed]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a runtime that disappeared after readiness cannot silently trigger another installation', async () => {
|
||||||
|
const options = startupOptions({ waitForDecision: async () => 'runtime-ready' as const })
|
||||||
|
await assert.rejects(runPrimaryBackendStartup(options), /no longer available/)
|
||||||
|
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -109,5 +109,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
|
|||||||
throw new FirstRunSetupResetError()
|
throw new FirstRunSetupResetError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (decision === 'runtime-ready') {
|
||||||
|
const current = await prepareLocalBackend()
|
||||||
|
if ((current as { kind?: string }).kind === 'bootstrap-needed') {
|
||||||
|
throw new Error('The completed local runtime is no longer available. Retry setup.')
|
||||||
|
}
|
||||||
|
return { kind: 'local', backend: await ensureLocalRuntime(current) }
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
|
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { afterEach, test } from 'vitest'
|
||||||
|
|
||||||
|
import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const roots: string[] = []
|
||||||
|
|
||||||
|
function tempRoot() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-'))
|
||||||
|
roots.push(root)
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(data: Buffer) {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir
|
||||||
|
// whose manifest describes the given files.
|
||||||
|
function fixture(files: Record<string, { data: string; ownedByPython?: boolean }>) {
|
||||||
|
const root = tempRoot()
|
||||||
|
const home = path.join(root, 'python home')
|
||||||
|
const venv = path.join(root, 'venv')
|
||||||
|
const source = path.join(root, 'bundle')
|
||||||
|
|
||||||
|
for (const dir of [home, venv, source]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(home, 'python.exe'), 'exe')
|
||||||
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`)
|
||||||
|
|
||||||
|
const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => {
|
||||||
|
const bytes = Buffer.from(data)
|
||||||
|
fs.writeFileSync(path.join(source, name), bytes)
|
||||||
|
|
||||||
|
return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(source, 'manifest.json'),
|
||||||
|
JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries })
|
||||||
|
)
|
||||||
|
|
||||||
|
return { home, venv, source }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-Windows platforms are left untouched', () => {
|
||||||
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), {
|
||||||
|
status: 'skipped',
|
||||||
|
reason: 'not windows'
|
||||||
|
})
|
||||||
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readPythonHome parses pyvenv.cfg home with spaces', () => {
|
||||||
|
const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
assert.equal(readPythonHome(venv), home)
|
||||||
|
assert.equal(readPythonHome(path.join(venv, 'missing')), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing CRT files are copied beside python.exe and verified', () => {
|
||||||
|
const { home, venv, source } = fixture({
|
||||||
|
'msvcp140.dll': { data: 'msvcp' },
|
||||||
|
'msvcp140_1.dll': { data: 'msvcp1' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] })
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1')
|
||||||
|
assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), [])
|
||||||
|
// Second launch: nothing to do.
|
||||||
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), {
|
||||||
|
status: 'ok',
|
||||||
|
pythonHome: home,
|
||||||
|
copied: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => {
|
||||||
|
const { home, venv, source } = fixture({
|
||||||
|
'msvcp140.dll': { data: 'good' },
|
||||||
|
'vcruntime140.dll': { data: 'bundled', ownedByPython: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale')
|
||||||
|
fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned')
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] })
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good')
|
||||||
|
assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tampered bundled file is refused and nothing is written', () => {
|
||||||
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } })
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered')
|
||||||
|
|
||||||
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
||||||
|
|
||||||
|
assert.equal(result.status, 'failed')
|
||||||
|
assert.match((result as { error: string }).error, /does not match its manifest digest/)
|
||||||
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unresolvable python home fails without throwing', () => {
|
||||||
|
const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n')
|
||||||
|
|
||||||
|
assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the shipped CRT set matches its manifest and covers the voice wheels', () => {
|
||||||
|
const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64')
|
||||||
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||||
|
const names = manifest.files.map(file => file.name)
|
||||||
|
|
||||||
|
for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) {
|
||||||
|
assert.ok(names.includes(required), `${required} must be bundled`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const bytes = fs.readFileSync(path.join(sourceDir, file.name))
|
||||||
|
|
||||||
|
assert.equal(bytes.length, file.size, file.name)
|
||||||
|
assert.equal(sha256(bytes), file.sha256, file.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the Windows package ships the CRT set as an extra resource', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
|
||||||
|
const resources = pkg.build.win.extraResources
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'),
|
||||||
|
'win.extraResources must carry vc-runtime/x64'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ensureRuntime places the CRT before handing the venv python to the backend', () => {
|
||||||
|
const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
|
||||||
|
const start = source.indexOf('async function ensureRuntime(')
|
||||||
|
const body = source.slice(start, source.indexOf('\nfunction ', start + 1))
|
||||||
|
|
||||||
|
const ensureIdx = body.indexOf('ensureWindowsVcRuntime(')
|
||||||
|
const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)')
|
||||||
|
|
||||||
|
assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime')
|
||||||
|
assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen')
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// ── Windows VC++ runtime for the managed Python ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Voice features load native wheels (onnxruntime for the wake word,
|
||||||
|
// ctranslate2 for local speech-to-text) that link against msvcp140.dll and
|
||||||
|
// msvcp140_1.dll. The managed CPython only ships vcruntime140*.dll, so on a
|
||||||
|
// Windows install without the system-wide VC++ Redistributable those imports
|
||||||
|
// fail with "DLL load failed / The specified module could not be found".
|
||||||
|
//
|
||||||
|
// The desktop package carries the Microsoft-signed VC143 CRT set and copies it
|
||||||
|
// next to the managed python.exe before every local backend start. That
|
||||||
|
// directory is the process image directory, which Windows searches first for
|
||||||
|
// extension-module dependencies, so a stale msvcp140.dll elsewhere on PATH can
|
||||||
|
// never win. Existing installs are healed on their next launch.
|
||||||
|
|
||||||
|
export interface VcRuntimeFile {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
// Files the managed CPython already ships. Never overwrite an existing copy:
|
||||||
|
// the interpreter owns it and replacing a loaded DLL is not possible anyway.
|
||||||
|
ownedByPython?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VcRuntimeManifest {
|
||||||
|
schemaVersion: 1
|
||||||
|
version: string
|
||||||
|
architecture: string
|
||||||
|
files: VcRuntimeFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcRuntimeResult =
|
||||||
|
| { status: 'skipped'; reason: string }
|
||||||
|
| { status: 'ok' | 'repaired'; pythonHome: string; copied: string[] }
|
||||||
|
| { status: 'failed'; pythonHome: string | null; copied: string[]; error: string }
|
||||||
|
|
||||||
|
const FILE_NAME_RE = /^[a-z0-9_]+\.dll$/i
|
||||||
|
const SHA256_RE = /^[0-9a-f]{64}$/
|
||||||
|
|
||||||
|
export function readPythonHome(venvRoot: string): string | null {
|
||||||
|
let text: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const match = /^\s*home\s*=\s*(.+?)\s*$/i.exec(line)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVcRuntimeManifest(sourceDir: string): VcRuntimeManifest {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(sourceDir, 'manifest.json'), 'utf8'))
|
||||||
|
|
||||||
|
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||||
|
throw new Error('invalid VC++ runtime manifest')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
if (!FILE_NAME_RE.test(file?.name) || !SHA256_RE.test(file?.sha256) || !Number.isInteger(file?.size)) {
|
||||||
|
throw new Error(`invalid VC++ runtime manifest entry: ${JSON.stringify(file?.name)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath: string): string {
|
||||||
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(filePath: string, file: VcRuntimeFile): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).size === file.size && sha256File(filePath) === file.sha256
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFile(sourceDir: string, pythonHome: string, file: VcRuntimeFile) {
|
||||||
|
const source = path.join(sourceDir, file.name)
|
||||||
|
|
||||||
|
if (!matches(source, file)) {
|
||||||
|
throw new Error(`bundled ${file.name} does not match its manifest digest`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = path.join(pythonHome, file.name)
|
||||||
|
const temp = `${target}.aiturk-${process.pid}.tmp`
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(source, temp)
|
||||||
|
|
||||||
|
if (!matches(temp, file)) {
|
||||||
|
throw new Error(`copied ${file.name} failed digest verification`)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(temp, target)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temp, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureWindowsVcRuntime({
|
||||||
|
platform,
|
||||||
|
venvRoot,
|
||||||
|
sourceDir
|
||||||
|
}: {
|
||||||
|
platform: NodeJS.Platform
|
||||||
|
venvRoot: string
|
||||||
|
sourceDir: string
|
||||||
|
}): VcRuntimeResult {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
return { status: 'skipped', reason: 'not windows' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonHome = readPythonHome(venvRoot)
|
||||||
|
|
||||||
|
if (!pythonHome || !fs.existsSync(path.join(pythonHome, 'python.exe'))) {
|
||||||
|
return { status: 'failed', pythonHome, copied: [], error: 'managed python home not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const target = path.join(pythonHome, file.name)
|
||||||
|
|
||||||
|
if (file.ownedByPython ? fs.existsSync(target) : matches(target, file)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
installFile(sourceDir, pythonHome, file)
|
||||||
|
copied.push(file.name)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'failed', pythonHome, copied, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: copied.length ? 'repaired' : 'ok', pythonHome, copied }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeVcRuntimeResult(result: VcRuntimeResult): string {
|
||||||
|
if (result.status === 'skipped') {
|
||||||
|
return `skipped (${result.reason})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
return `failed for ${result.pythonHome ?? 'unknown python home'}: ${result.error}` +
|
||||||
|
(result.copied.length ? ` (copied before failure: ${result.copied.join(', ')})` : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.status === 'repaired'
|
||||||
|
? `installed ${result.copied.join(', ')} into ${result.pythonHome}`
|
||||||
|
: `verified in ${result.pythonHome}`
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.7",
|
"version": "1.0.0-beta.21",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -290,7 +290,17 @@
|
|||||||
"nsis",
|
"nsis",
|
||||||
"msi"
|
"msi"
|
||||||
],
|
],
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false,
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "assets/windows/vc-runtime/x64",
|
||||||
|
"to": "vc-runtime/x64",
|
||||||
|
"filter": [
|
||||||
|
"*.dll",
|
||||||
|
"manifest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||||
// separately via stage-native-deps).
|
// separately via stage-native-deps).
|
||||||
import { build } from 'esbuild'
|
import { build } from 'esbuild'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
import { resolve, dirname } from 'node:path'
|
import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { mkdirSync } from 'node:fs'
|
import { mkdirSync, cpSync, existsSync } from 'node:fs'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const root = resolve(here, '..')
|
const root = resolve(here, '..')
|
||||||
@@ -63,3 +64,25 @@ await build({
|
|||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
})
|
})
|
||||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
||||||
|
for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-host.css', 'mobile-preload.cjs']) {
|
||||||
|
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||||
|
}
|
||||||
|
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||||
|
// The phone window only needs the shared stylesheet and the QR drawer (which
|
||||||
|
// imports ./vendor/qrcode.mjs, staged below).
|
||||||
|
for (const name of ['style.css', 'qr.mjs']) {
|
||||||
|
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
|
||||||
|
}
|
||||||
|
// A clean `npm ci` hoists the mobile vendor packages to the workspace root, so
|
||||||
|
// look them up along Node's resolution path from apps/mobile instead of
|
||||||
|
// assuming apps/mobile/node_modules exists.
|
||||||
|
const mobileRequire = createRequire(resolve(root, '../mobile/package.json'))
|
||||||
|
function mobilePackageDir(name) {
|
||||||
|
for (const base of mobileRequire.resolve.paths(name) ?? []) {
|
||||||
|
const dir = resolve(base, name)
|
||||||
|
if (existsSync(resolve(dir, 'package.json'))) return dir
|
||||||
|
}
|
||||||
|
throw new Error(`${name} is not installed for apps/mobile; run npm ci at the repository root`)
|
||||||
|
}
|
||||||
|
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||||
|
cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const output = process.argv[2]
|
||||||
|
if (!output || !path.isAbsolute(output)) throw new Error('An absolute evidence output directory is required')
|
||||||
|
await mkdir(output, { recursive: true })
|
||||||
|
const port = 5178
|
||||||
|
const server = spawn(process.execPath, [path.resolve(root, '../../node_modules/vite/bin/vite.js'), '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let log = ''
|
||||||
|
const capture = chunk => { log = (log + String(chunk)).slice(-12000) }
|
||||||
|
server.stdout.on('data', capture); server.stderr.on('data', capture)
|
||||||
|
let browser
|
||||||
|
try {
|
||||||
|
let ready = false
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (server.exitCode !== null) throw new Error('Preview server exited: ' + log)
|
||||||
|
try { ready = (await fetch(`http://127.0.0.1:${port}/visual-fixtures/codex-link-settings.html`)).ok } catch {}
|
||||||
|
if (ready) break
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
if (!ready) throw new Error('Preview server not ready: ' + log)
|
||||||
|
browser = await chromium.launch({ channel: 'chrome', headless: true })
|
||||||
|
const evidence = []
|
||||||
|
for (const width of [360, 390, 414, 1280]) {
|
||||||
|
const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: 'reduce' })
|
||||||
|
const errors = []
|
||||||
|
page.on('pageerror', error => errors.push(error.message))
|
||||||
|
await page.goto(`http://127.0.0.1:${port}/visual-fixtures/codex-link-settings.html`, { waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||||
|
await page.getByText('Proje sohbeti · örnek kayıt').waitFor({ timeout: 90000 })
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
const layout = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, buttons: [...document.querySelectorAll('button:not([role=switch]), input, select, label[for="codex-link-enabled"], label[for="codex-link-compression"]')].map(element => ({ text: element.textContent || element.id, height: element.getBoundingClientRect().height })) }))
|
||||||
|
if (layout.scroll > width) throw new Error(`Horizontal overflow at ${width}: ${layout.scroll}`)
|
||||||
|
await writeFile(path.join(output, `layout-${width}.json`), JSON.stringify(layout, null, 2) + '\n')
|
||||||
|
await page.screenshot({ path: path.join(output, `codex-link-${width}.png`), fullPage: true })
|
||||||
|
if (layout.buttons.some(button => button.height < 43.5)) throw new Error(`Small touch target at ${width}: ${JSON.stringify(layout.buttons.filter(button => button.height < 43.5))}`)
|
||||||
|
if (errors.length) throw new Error(errors.join('\n'))
|
||||||
|
// Tap the label as a finger would, then verify the manager-confirmed value.
|
||||||
|
await page.locator('label[for="codex-link-enabled"]').click()
|
||||||
|
await page.waitForFunction(() => document.querySelector('#codex-link-enabled')?.getAttribute('aria-checked') === 'false')
|
||||||
|
await page.getByRole('button', { name: 'Şimdi sıkıştır' }).scrollIntoViewIfNeeded()
|
||||||
|
await page.screenshot({ path: path.join(output, `codex-link-${width}-history.png`), fullPage: true })
|
||||||
|
evidence.push({ width, horizontal_overflow: false, touch_targets_passed: true, preference_update_passed: true, errors })
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
await writeFile(path.join(output, 'layout-result.json'), JSON.stringify({ synthetic_fixture: true, real_manager: false, checks: evidence }, null, 2) + '\n')
|
||||||
|
console.log(JSON.stringify({ passed: evidence.length, output }))
|
||||||
|
} finally {
|
||||||
|
await browser?.close()
|
||||||
|
server.kill()
|
||||||
|
await writeFile(path.join(output, 'preview-server.log'), log)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { I18nProvider } from '@/i18n/context'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CodexLinkContextView,
|
||||||
|
CodexLinkControlRequest,
|
||||||
|
CodexLinkControlResponse,
|
||||||
|
CodexLinkSettings as SavedSettings
|
||||||
|
} from '../../../codex-link-control.types'
|
||||||
|
|
||||||
|
import { CodexLinkSettings } from './codex-link-settings'
|
||||||
|
|
||||||
|
let settings: SavedSettings
|
||||||
|
let views: CodexLinkContextView[]
|
||||||
|
let writes: CodexLinkControlRequest[]
|
||||||
|
let failWrite: boolean
|
||||||
|
|
||||||
|
const control = vi.fn(async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||||
|
const base = {
|
||||||
|
contract_version: 'codex-link/1' as const,
|
||||||
|
request_id: request.request_id,
|
||||||
|
operation_id: request.operation_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.kind === 'status_get') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'status_result',
|
||||||
|
status: {
|
||||||
|
settings_revision: settings.settings_revision,
|
||||||
|
runtime_mode: 'managed',
|
||||||
|
native_available: true,
|
||||||
|
external_admission: 'open',
|
||||||
|
needs_restart: false,
|
||||||
|
catalog_state: 'fresh',
|
||||||
|
active_turns: 0,
|
||||||
|
uncertain_operations: 0,
|
||||||
|
last_operation_id: null,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.kind === 'context_views_get') {
|
||||||
|
return { ...base, kind: 'context_views_result', settings_revision: settings.settings_revision, views }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.kind === 'settings_update') {
|
||||||
|
writes.push(request)
|
||||||
|
|
||||||
|
if (failWrite) {
|
||||||
|
throw new Error('outcome_unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = { ...settings, ...request.patch, settings_revision: settings.settings_revision + 1 }
|
||||||
|
} else if (request.kind !== 'settings_get') {
|
||||||
|
writes.push(request)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: 'control_accepted',
|
||||||
|
settings_revision: settings.settings_revision,
|
||||||
|
operation_state: 'accepted',
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, kind: 'settings_result', settings, available_sources: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const mount = () =>
|
||||||
|
render(
|
||||||
|
<I18nProvider configClient={null} initialLocale="tr">
|
||||||
|
<CodexLinkSettings />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
control.mockClear()
|
||||||
|
writes = []
|
||||||
|
views = []
|
||||||
|
failWrite = false
|
||||||
|
settings = {
|
||||||
|
settings_revision: 7,
|
||||||
|
enabled: false,
|
||||||
|
compression_enabled: true,
|
||||||
|
threshold_tokens: 250000,
|
||||||
|
source_binding_handle: null,
|
||||||
|
compact_route_id: 'Compact',
|
||||||
|
legacy_intent_imported: true,
|
||||||
|
ownership_manifest_revision: 2,
|
||||||
|
activation_state: 'disabled'
|
||||||
|
}
|
||||||
|
vi.stubGlobal('hermesDesktop', { codexLink: { control } } satisfies Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'>)
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Codex connection settings', () => {
|
||||||
|
it('shows the persisted disabled preference, without turning it on during mount', async () => {
|
||||||
|
mount()
|
||||||
|
const toggle = await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' })
|
||||||
|
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||||
|
expect(writes).toEqual([])
|
||||||
|
expect((screen.getByLabelText('Sıkıştırma eşiği (token)') as HTMLInputElement).value).toBe('250000')
|
||||||
|
})
|
||||||
|
it('uses a checkpoint-safe settings patch and rejects decimal/unsafe thresholds', async () => {
|
||||||
|
mount()
|
||||||
|
const input = await screen.findByLabelText('Sıkıştırma eşiği (token)')
|
||||||
|
fireEvent.change(input, { target: { value: '1.5' } })
|
||||||
|
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
fireEvent.change(input, { target: { value: '9007199254740992' } })
|
||||||
|
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
fireEvent.change(input, { target: { value: '125000' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||||
|
await waitFor(() => expect(writes).toHaveLength(1))
|
||||||
|
expect(writes[0]).toMatchObject({
|
||||||
|
kind: 'settings_update',
|
||||||
|
expected_settings_revision: 7,
|
||||||
|
patch: { threshold_tokens: 125000 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('keeps an uncertain operation across remount and reconciles the exact ID/body', async () => {
|
||||||
|
failWrite = true
|
||||||
|
const first = mount()
|
||||||
|
fireEvent.click(await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' }))
|
||||||
|
await screen.findByRole('button', { name: 'İşlemi kontrol et' })
|
||||||
|
expect(writes).toHaveLength(1)
|
||||||
|
const original = writes[0]
|
||||||
|
first.unmount()
|
||||||
|
mount()
|
||||||
|
failWrite = false
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'İşlemi kontrol et' }))
|
||||||
|
await waitFor(() => expect(writes).toHaveLength(2))
|
||||||
|
expect(writes[1]).toEqual(original)
|
||||||
|
await waitFor(() => expect(sessionStorage.getItem('aiturk.codex-link.pending-control.v1')).toBeNull())
|
||||||
|
})
|
||||||
|
it('does not present unknown token measurement as zero or enable unsafe compaction', async () => {
|
||||||
|
views = [
|
||||||
|
{
|
||||||
|
view_handle: 'view-a',
|
||||||
|
can_compact: false,
|
||||||
|
label: 'Ayrı sohbet',
|
||||||
|
checkpoint_revision: 3,
|
||||||
|
current_model_label: 'Model A',
|
||||||
|
limiting_target_id: null,
|
||||||
|
target_budgets: null,
|
||||||
|
active_summary_count: 2,
|
||||||
|
summary_tokens: null,
|
||||||
|
summary_token_measurement: null,
|
||||||
|
last_operation_id: null,
|
||||||
|
last_operation_state: null,
|
||||||
|
last_operation_error: null,
|
||||||
|
compact_attempts: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mount()
|
||||||
|
await screen.findByText('Ayrı sohbet')
|
||||||
|
expect(screen.getAllByText(/Henüz ölçülmedi/).length).toBeGreaterThanOrEqual(3)
|
||||||
|
expect((screen.getByRole('button', { name: 'Şimdi sıkıştır' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
it('binds manual compaction to a manager view and its exact checkpoint revision', async () => {
|
||||||
|
views = [
|
||||||
|
{
|
||||||
|
view_handle: 'opaque-view-a',
|
||||||
|
can_compact: true,
|
||||||
|
label: 'Sohbet A',
|
||||||
|
checkpoint_revision: 9,
|
||||||
|
current_model_label: 'Model A',
|
||||||
|
limiting_target_id: null,
|
||||||
|
target_budgets: null,
|
||||||
|
active_summary_count: 0,
|
||||||
|
summary_tokens: null,
|
||||||
|
summary_token_measurement: null,
|
||||||
|
last_operation_id: null,
|
||||||
|
last_operation_state: null,
|
||||||
|
last_operation_error: null,
|
||||||
|
compact_attempts: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mount()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Şimdi sıkıştır' }))
|
||||||
|
await waitFor(() => expect(writes).toHaveLength(1))
|
||||||
|
expect(writes[0]).toMatchObject({
|
||||||
|
kind: 'context_compact_view',
|
||||||
|
view_handle: 'opaque-view-a',
|
||||||
|
expected_checkpoint_revision: 9
|
||||||
|
})
|
||||||
|
expect(writes[0]).not.toHaveProperty('thread_id')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||||
|
import { Cpu } from '@/lib/icons'
|
||||||
|
|
||||||
|
import { validateControlRequest } from '../../../codex-link-control'
|
||||||
|
import type {
|
||||||
|
CodexLinkContextView,
|
||||||
|
CodexLinkControlRequest,
|
||||||
|
CodexLinkControlResponse,
|
||||||
|
CodexLinkSettingsResult,
|
||||||
|
CodexLinkSettingsUpdate,
|
||||||
|
CodexLinkStatusSnapshot
|
||||||
|
} from '../../../codex-link-control.types'
|
||||||
|
|
||||||
|
import { SettingsContent, SettingsSection } from './primitives'
|
||||||
|
|
||||||
|
const PENDING_KEY = 'aiturk.codex-link.pending-control.v1'
|
||||||
|
|
||||||
|
function envelope() {
|
||||||
|
return {
|
||||||
|
contract_version: 'codex-link/1' as const,
|
||||||
|
request_id: crypto.randomUUID(),
|
||||||
|
operation_id: crypto.randomUUID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPending(): CodexLinkControlRequest | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: unknown = JSON.parse(raw)
|
||||||
|
validateControlRequest(value)
|
||||||
|
|
||||||
|
return ['settings_update', 'catalog_sync', 'context_compact_view'].includes(value.kind) ? value : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedPending(value: CodexLinkControlRequest | null) {
|
||||||
|
if (value) {
|
||||||
|
sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(PENDING_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Preference({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (value: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-center gap-4 py-3" htmlFor={id}>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-medium">{label}</span>
|
||||||
|
<span className="mt-1 block text-sm text-muted-foreground">{description}</span>
|
||||||
|
</span>
|
||||||
|
<Switch aria-label={label} checked={checked} disabled={disabled} id={id} onCheckedChange={onChange} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexLinkSettings() {
|
||||||
|
const copy = useCodexLinkCopy()
|
||||||
|
const [settings, setSettings] = useState<CodexLinkSettingsResult | null>(null)
|
||||||
|
const [status, setStatus] = useState<CodexLinkStatusSnapshot | null>(null)
|
||||||
|
const [views, setViews] = useState<CodexLinkContextView[]>([])
|
||||||
|
const [threshold, setThreshold] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [pending, setPending] = useState(readPending)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const mounted = useRef(true)
|
||||||
|
const writeLock = useRef(false)
|
||||||
|
const readLock = useRef(false)
|
||||||
|
const generation = useRef(0)
|
||||||
|
const savedRevision = useRef(-1)
|
||||||
|
const bridge = window.hermesDesktop?.codexLink
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!bridge || readLock.current || writeLock.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readLock.current = true
|
||||||
|
const current = generation.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
['settings_get', 'status_get', 'context_views_get'].map(kind =>
|
||||||
|
bridge.control({ ...envelope(), kind } as CodexLinkControlRequest)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!mounted.current || current !== generation.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [saved, live, chats] = results
|
||||||
|
|
||||||
|
if (saved.kind !== 'settings_result' || live.kind !== 'status_result' || chats.kind !== 'context_views_result') {
|
||||||
|
throw new Error('read_failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write in another window may fall between these three reads. Never paint a mixed revision.
|
||||||
|
if (
|
||||||
|
saved.settings.settings_revision !== live.status.settings_revision ||
|
||||||
|
saved.settings.settings_revision !== chats.settings_revision
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved.settings.settings_revision < savedRevision.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedRevision.current !== saved.settings.settings_revision) {
|
||||||
|
setThreshold(String(saved.settings.threshold_tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
savedRevision.current = saved.settings.settings_revision
|
||||||
|
setSettings(saved)
|
||||||
|
setStatus(live.status)
|
||||||
|
setViews(chats.views)
|
||||||
|
setError('')
|
||||||
|
} catch {
|
||||||
|
if (mounted.current && current === generation.current) {
|
||||||
|
setError(copy.failed)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
readLock.current = false
|
||||||
|
}
|
||||||
|
}, [bridge, copy.failed])
|
||||||
|
|
||||||
|
// This is a component lifetime flag, not a mirror of a reactive atom/value.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
void refresh()
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
void refresh()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted.current = false
|
||||||
|
// The current numeric generation is invalidated; it is not a captured DOM ref.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
generation.current++
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const send = async (request: CodexLinkControlRequest) => {
|
||||||
|
if (!bridge || writeLock.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeLock.current = true
|
||||||
|
generation.current++
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
setNotice('')
|
||||||
|
|
||||||
|
// Persist the exact operation before dispatch: an uncertain reply must reuse this ID and body.
|
||||||
|
try {
|
||||||
|
savedPending(request)
|
||||||
|
setPending(request)
|
||||||
|
} catch {
|
||||||
|
writeLock.current = false
|
||||||
|
setBusy(false)
|
||||||
|
setError(copy.failed)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: CodexLinkControlResponse = await bridge.control(request)
|
||||||
|
|
||||||
|
if (!mounted.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(result.kind === 'control_error' && result.error.recovery_action === 'reconcile_only') ||
|
||||||
|
(result.kind === 'control_accepted' && result.operation_state === 'indeterminate')
|
||||||
|
) {
|
||||||
|
setError(copy.unknown)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedPending(null)
|
||||||
|
setPending(null)
|
||||||
|
|
||||||
|
if (result.kind === 'control_error') {
|
||||||
|
setNotice(
|
||||||
|
result.error.code.includes('conflict') || result.error.code.includes('cas')
|
||||||
|
? copy.conflict
|
||||||
|
: result.error.message
|
||||||
|
)
|
||||||
|
} else if (result.kind === 'settings_result') {
|
||||||
|
savedRevision.current = result.settings.settings_revision
|
||||||
|
setSettings(result)
|
||||||
|
setThreshold(String(result.settings.threshold_tokens))
|
||||||
|
setNotice(copy.saved)
|
||||||
|
} else if (result.kind === 'control_accepted') {
|
||||||
|
setNotice(result.error?.message ?? copy.accepted)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mounted.current) {
|
||||||
|
setError(copy.unknown)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
writeLock.current = false
|
||||||
|
|
||||||
|
if (mounted.current) {
|
||||||
|
setBusy(false)
|
||||||
|
void refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (patch: CodexLinkSettingsUpdate['patch']) => {
|
||||||
|
if (settings && !pending) {
|
||||||
|
void send({
|
||||||
|
...envelope(),
|
||||||
|
kind: 'settings_update',
|
||||||
|
expected_settings_revision: settings.settings.settings_revision,
|
||||||
|
patch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdValue = /^[0-9]+$/.test(threshold) ? Number(threshold) : NaN
|
||||||
|
const validThreshold = Number.isSafeInteger(thresholdValue) && thresholdValue > 0
|
||||||
|
const locked = busy || pending !== null
|
||||||
|
const number = (n: number | null) => (n === null ? copy.unmeasured : n.toLocaleString())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContent>
|
||||||
|
<div className="min-w-0 py-4" data-testid="codex-link-settings">
|
||||||
|
<SettingsSection icon={Cpu} title={copy.title}>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">{copy.intro}</p>
|
||||||
|
{!bridge ? (
|
||||||
|
<p role="alert">{copy.unavailable}</p>
|
||||||
|
) : !settings ? (
|
||||||
|
<p role="status">{error || copy.loading}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Preference
|
||||||
|
checked={settings.settings.enabled}
|
||||||
|
description={copy.enabledHelp}
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-enabled"
|
||||||
|
label={copy.enabled}
|
||||||
|
onChange={enabled => update({ enabled })}
|
||||||
|
/>
|
||||||
|
<Preference
|
||||||
|
checked={settings.settings.compression_enabled}
|
||||||
|
description={copy.compressionHelp}
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-compression"
|
||||||
|
label={copy.compression}
|
||||||
|
onChange={compression_enabled => update({ compression_enabled })}
|
||||||
|
/>
|
||||||
|
<div className="my-4 space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="codex-link-threshold">
|
||||||
|
{copy.threshold}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
aria-describedby="codex-link-threshold-help"
|
||||||
|
aria-invalid={!validThreshold}
|
||||||
|
className="min-h-11 w-full min-w-0 sm:w-48"
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-threshold"
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={event => setThreshold(event.target.value)}
|
||||||
|
value={threshold}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
disabled={locked || !validThreshold || thresholdValue === settings.settings.threshold_tokens}
|
||||||
|
onClick={() => update({ threshold_tokens: thresholdValue })}
|
||||||
|
>
|
||||||
|
{copy.save}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground" id="codex-link-threshold-help">
|
||||||
|
{validThreshold ? copy.thresholdHelp : copy.invalidThreshold}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="my-4 space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="codex-link-source">
|
||||||
|
{copy.source}
|
||||||
|
</label>
|
||||||
|
{settings.available_sources.length ? (
|
||||||
|
<select
|
||||||
|
className="min-h-11 w-full min-w-0 rounded-md border bg-background px-3 text-sm"
|
||||||
|
disabled={locked}
|
||||||
|
id="codex-link-source"
|
||||||
|
onChange={event => update({ source_binding_handle: event.target.value })}
|
||||||
|
value={settings.settings.source_binding_handle ?? ''}
|
||||||
|
>
|
||||||
|
{!settings.settings.source_binding_handle && (
|
||||||
|
<option disabled value="">
|
||||||
|
{copy.noSource}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{settings.available_sources.map(source => (
|
||||||
|
<option
|
||||||
|
disabled={!source.authorized}
|
||||||
|
key={source.source_binding_handle}
|
||||||
|
value={source.source_binding_handle}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.noSource}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" role="status">
|
||||||
|
{copy.activation[settings.settings.activation_state]}
|
||||||
|
{status ? ` · ${copy.admission[status.external_admission]}` : ''}
|
||||||
|
</p>
|
||||||
|
{status?.needs_restart && <p className="mt-2 text-sm text-muted-foreground">{copy.restart}</p>}
|
||||||
|
{status?.error && (
|
||||||
|
<p className="mt-2 text-sm" role="alert">
|
||||||
|
{status.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => void send({ ...envelope(), kind: 'catalog_sync' })}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{copy.sync}
|
||||||
|
</Button>
|
||||||
|
<Button className="min-h-11" disabled={busy} onClick={() => void refresh()} variant="outline">
|
||||||
|
{copy.refresh}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-sm" role="alert">
|
||||||
|
{copy.unknown}
|
||||||
|
</p>
|
||||||
|
<Button className="min-h-11" disabled={busy} onClick={() => void send(pending)}>
|
||||||
|
{copy.reconcile}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && settings && (
|
||||||
|
<p className="mt-3 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className="mt-3 text-sm" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
{settings && (
|
||||||
|
<SettingsSection icon={Cpu} title={copy.chats}>
|
||||||
|
{!views.length && <p className="text-sm text-muted-foreground">{copy.empty}</p>}
|
||||||
|
{views.map(view => {
|
||||||
|
const target = view.target_budgets?.find(item => item.target_id === view.limiting_target_id)
|
||||||
|
|
||||||
|
const hardLimit = target
|
||||||
|
? Math.min(
|
||||||
|
target.max_input_tokens ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
target.context_window_tokens - target.output_cap_tokens - target.safety_margin_tokens
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const limit =
|
||||||
|
hardLimit === null
|
||||||
|
? null
|
||||||
|
: settings.settings.compression_enabled
|
||||||
|
? Math.min(settings.settings.threshold_tokens, hardLimit)
|
||||||
|
: hardLimit
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="min-w-0 border-b py-4 last:border-b-0" key={view.view_handle}>
|
||||||
|
<h3 className="break-words text-sm font-medium">{view.label}</h3>
|
||||||
|
<p className="break-words text-sm text-muted-foreground">{view.current_model_label}</p>
|
||||||
|
<dl className="my-3 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2 text-sm">
|
||||||
|
<dt>{copy.current}</dt>
|
||||||
|
<dd className="break-words">
|
||||||
|
{number(target?.input_tokens ?? null)}
|
||||||
|
{target?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||||
|
{target?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||||
|
</dd>
|
||||||
|
<dt>{copy.limit}</dt>
|
||||||
|
<dd className="break-words">{number(limit)}</dd>
|
||||||
|
<dt>{copy.summaries}</dt>
|
||||||
|
<dd>
|
||||||
|
{view.active_summary_count} · {number(view.summary_tokens)}{' '}
|
||||||
|
{view.summary_tokens !== null ? copy.tokens : ''}
|
||||||
|
{view.summary_token_measurement?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
||||||
|
{view.summary_token_measurement?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{view.last_operation_state && (
|
||||||
|
<p className="my-2 text-sm" role="status">
|
||||||
|
{copy.operation[view.last_operation_state]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.last_operation_error && (
|
||||||
|
<p className="my-2 text-sm" role="alert">
|
||||||
|
{view.last_operation_error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.compact_attempts.map(attempt => (
|
||||||
|
<p className="break-words text-sm text-muted-foreground" key={attempt.attempt_id}>
|
||||||
|
{attempt.member_label} · {copy.operation[attempt.state]}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
className="mt-2 min-h-11"
|
||||||
|
disabled={locked || !view.can_compact}
|
||||||
|
onClick={() =>
|
||||||
|
void send({
|
||||||
|
...envelope(),
|
||||||
|
kind: 'context_compact_view',
|
||||||
|
view_handle: view.view_handle,
|
||||||
|
expected_checkpoint_revision: view.checkpoint_revision
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{copy.compact}
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { AiturkLogin } from '@/components/aiturk-login'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -80,7 +81,10 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
|
|||||||
|
|
||||||
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: CustomEndpointsSettingsProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const c = useAiturkCopy().custom
|
const aiturkCopy = useAiturkCopy()
|
||||||
|
const c = aiturkCopy.custom
|
||||||
|
const loginCopy = aiturkCopy.login
|
||||||
|
const loginLabel = loginCopy.settingsSignIn
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
@@ -129,6 +133,31 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// One-click sign-in saved the TurkServis provider in the main process:
|
||||||
|
// reload the list and tell the app its default model changed.
|
||||||
|
async function handleAiturkConnected() {
|
||||||
|
try {
|
||||||
|
const data = await getCustomEndpoints()
|
||||||
|
setEndpoints(data.endpoints)
|
||||||
|
const current = data.endpoints.find(endpoint => endpoint.id === 'turkservis')
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
setForm(formFromEndpoint(current))
|
||||||
|
setDiscoveredModels(current.models)
|
||||||
|
|
||||||
|
if (current.is_current) {
|
||||||
|
onMainModelChanged?.(current.id, current.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerHaptic('success')
|
||||||
|
onConfigSaved?.()
|
||||||
|
notify({ kind: 'success', message: loginCopy.connected })
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err, c.loadFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -298,10 +327,13 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<Button className="mb-3" onClick={() => {
|
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||||
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
<AiturkLogin label={loginLabel} onConnected={handleAiturkConnected} />
|
||||||
setDiscoveredModels([])
|
<Button onClick={() => {
|
||||||
}} variant="outline">{c.preset}</Button>
|
setForm({ ...EMPTY_FORM, name: 'TurkServis · AITURK', baseUrl: AITURK_API_BASE_URL })
|
||||||
|
setDiscoveredModels([])
|
||||||
|
}} variant="outline">{c.preset}</Button>
|
||||||
|
</div>
|
||||||
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
<SectionHeading icon={Plus} title={form.id ? c.edit : c.add} />
|
||||||
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
<div className="grid gap-3 rounded-md border border-border/50 p-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { IconDeviceMobile } from '@tabler/icons-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router'
|
import { useLocation, useNavigate } from 'react-router'
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import { KbdCombo } from '@/components/ui/kbd'
|
|||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||||
|
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -45,6 +48,7 @@ import { AboutSettings } from './about-settings'
|
|||||||
import { AppearanceSettings } from './appearance-settings'
|
import { AppearanceSettings } from './appearance-settings'
|
||||||
import { BillingSettings } from './billing'
|
import { BillingSettings } from './billing'
|
||||||
import { ConfigSettings } from './config-settings'
|
import { ConfigSettings } from './config-settings'
|
||||||
|
import { CodexLinkSettings } from './codex-link-settings'
|
||||||
import { SECTIONS } from './constants'
|
import { SECTIONS } from './constants'
|
||||||
import { GatewaySettings } from './gateway-settings'
|
import { GatewaySettings } from './gateway-settings'
|
||||||
import { KeybindSettings } from './keybind-settings'
|
import { KeybindSettings } from './keybind-settings'
|
||||||
@@ -59,6 +63,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||||
'providers',
|
'providers',
|
||||||
'gateway',
|
'gateway',
|
||||||
|
'codex-link',
|
||||||
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
||||||
// so saved `?tab=connections` deep links still resolve (redirected below).
|
// so saved `?tab=connections` deep links still resolve (redirected below).
|
||||||
'connections',
|
'connections',
|
||||||
@@ -73,6 +78,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||||||
|
|
||||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const aiturkCopy = useAiturkCopy()
|
||||||
|
const codexLinkCopy = useCodexLinkCopy()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { hash, pathname, search } = useLocation()
|
const { hash, pathname, search } = useLocation()
|
||||||
|
|
||||||
@@ -249,6 +256,28 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
label: t.settings.nav.gateway,
|
label: t.settings.nav.gateway,
|
||||||
onSelect: () => setActiveView('gateway')
|
onSelect: () => setActiveView('gateway')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
active: activeView === 'codex-link',
|
||||||
|
icon: Cpu,
|
||||||
|
id: 'codex-link',
|
||||||
|
label: codexLinkCopy.title,
|
||||||
|
onSelect: () => setActiveView('codex-link')
|
||||||
|
},
|
||||||
|
// "Telefon bağlantısı" lives in its own small window (QR + code); this
|
||||||
|
// entry only opens it. Present only where the desktop bridge offers it.
|
||||||
|
...(window.hermesDesktop?.aiturkMobile
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
active: false,
|
||||||
|
icon: IconDeviceMobile,
|
||||||
|
id: 'aiturk-phone',
|
||||||
|
label: aiturkCopy.mobile.nav,
|
||||||
|
onSelect: () => {
|
||||||
|
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
active: activeView === 'keybinds',
|
active: activeView === 'keybinds',
|
||||||
icon: Keyboard,
|
icon: Keyboard,
|
||||||
@@ -302,7 +331,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
onSelect: () => setActiveView('about')
|
onSelect: () => setActiveView('about')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView]
|
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||||
@@ -396,6 +425,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
// 'connections' renders the unified page too so the frame before
|
// 'connections' renders the unified page too so the frame before
|
||||||
// the alias redirect lands doesn't flash the fallback view.
|
// the alias redirect lands doesn't flash the fallback view.
|
||||||
<GatewaySettings />
|
<GatewaySettings />
|
||||||
|
) : activeView === 'codex-link' ? (
|
||||||
|
<CodexLinkSettings />
|
||||||
) : activeView === 'keybinds' ? (
|
) : activeView === 'keybinds' ? (
|
||||||
<KeybindSettings />
|
<KeybindSettings />
|
||||||
) : activeView.startsWith('config:') ? (
|
) : activeView.startsWith('config:') ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type SettingsView =
|
|||||||
| 'about'
|
| 'about'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
| 'connections'
|
| 'connections'
|
||||||
|
| 'codex-link'
|
||||||
| 'gateway'
|
| 'gateway'
|
||||||
| 'keybinds'
|
| 'keybinds'
|
||||||
| 'keys'
|
| 'keys'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
setModelVisibilityOpen,
|
setModelVisibilityOpen,
|
||||||
setVisibleModels
|
setVisibleModels
|
||||||
} from '@/store/model-visibility'
|
} from '@/store/model-visibility'
|
||||||
|
import { openPreview } from '@/store/preview'
|
||||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||||
|
|
||||||
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
||||||
@@ -24,6 +25,7 @@ beforeAll(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getGlobalModelOptions = vi.fn()
|
const getGlobalModelOptions = vi.fn()
|
||||||
|
vi.mock('@/store/preview', () => ({ openPreview: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
||||||
@@ -88,6 +90,22 @@ function renderMenu() {
|
|||||||
// the kanban board would end up disagreeing about what "my models" means —
|
// the kanban board would end up disagreeing about what "my models" means —
|
||||||
// which is exactly the drift extracting this component was meant to prevent.
|
// which is exactly the drift extracting this component was meant to prevent.
|
||||||
describe('the catalog owns model curation', () => {
|
describe('the catalog owns model curation', () => {
|
||||||
|
it('opens the chosen TurkServis model details without selecting that model', async () => {
|
||||||
|
getGlobalModelOptions.mockResolvedValue({ providers: [{
|
||||||
|
models: ['Outer'], name: 'TurkServis', slug: 'turkservis', api_url: 'https://ai.turkservis.online/v1'
|
||||||
|
}] })
|
||||||
|
const select = renderMenu()
|
||||||
|
const trigger = await screen.findByRole('menuitem', { name: /Outer/ })
|
||||||
|
|
||||||
|
fireEvent.keyDown(trigger, { key: 'ArrowRight' })
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ }))
|
||||||
|
|
||||||
|
expect(openPreview).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
kind: 'url', url: 'https://turkservis.online/router?model=Outer'
|
||||||
|
}))
|
||||||
|
expect(select).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('honours the stored Edit Models shortlist', async () => {
|
it('honours the stored Edit Models shortlist', async () => {
|
||||||
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
|||||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||||
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
||||||
import { normalize } from '@/lib/text'
|
import { normalize } from '@/lib/text'
|
||||||
|
import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details'
|
||||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
modelVisibilityKey,
|
modelVisibilityKey,
|
||||||
setModelVisibilityOpen
|
setModelVisibilityOpen
|
||||||
} from '@/store/model-visibility'
|
} from '@/store/model-visibility'
|
||||||
|
import { openPreview } from '@/store/preview'
|
||||||
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
||||||
import { $defaultReasoningEffort } from '@/store/session'
|
import { $defaultReasoningEffort } from '@/store/session'
|
||||||
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||||
@@ -588,6 +590,12 @@ export function ModelCatalogMenu({
|
|||||||
fastControl={fastControl}
|
fastControl={fastControl}
|
||||||
isActive={isCurrent}
|
isActive={isCurrent}
|
||||||
model={family.id}
|
model={family.id}
|
||||||
|
onOpenDetails={turkservisComboDetailsUrl(group.provider.api_url, family.id) ? () => {
|
||||||
|
const url = turkservisComboDetailsUrl(group.provider.api_url, family.id)!
|
||||||
|
|
||||||
|
closeMenu()
|
||||||
|
openPreview({ kind: 'url', label: t.shell.modelOptions.comboDetails, source: url, url })
|
||||||
|
} : undefined}
|
||||||
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
|
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
|
||||||
onSetOptions={patch =>
|
onSetOptions={patch =>
|
||||||
controller.setOptions(patch, {
|
controller.setOptions(patch, {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function renderSubmenu(opts: {
|
|||||||
fastControl: FastControl
|
fastControl: FastControl
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
onSelectModel?: (model: string) => void
|
onSelectModel?: (model: string) => void
|
||||||
|
onOpenDetails?: () => void
|
||||||
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
|
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
|
||||||
reasoning: boolean
|
reasoning: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -43,6 +44,7 @@ function renderSubmenu(opts: {
|
|||||||
fastControl={opts.fastControl}
|
fastControl={opts.fastControl}
|
||||||
isActive={opts.isActive ?? true}
|
isActive={opts.isActive ?? true}
|
||||||
model="m1"
|
model="m1"
|
||||||
|
onOpenDetails={opts.onOpenDetails}
|
||||||
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
||||||
onSetOptions={opts.onSetOptions}
|
onSetOptions={opts.onSetOptions}
|
||||||
provider="p1"
|
provider="p1"
|
||||||
@@ -60,6 +62,19 @@ function renderSubmenu(opts: {
|
|||||||
// ever writes directly again, picking an effort for a kanban card would reach
|
// ever writes directly again, picking an effort for a kanban card would reach
|
||||||
// over and change the user's live chat.
|
// over and change the user's live chat.
|
||||||
describe('ModelEditSubmenu reports edits without performing them', () => {
|
describe('ModelEditSubmenu reports edits without performing them', () => {
|
||||||
|
it('opens read-only details without selecting a model or changing its options', () => {
|
||||||
|
const onOpenDetails = vi.fn()
|
||||||
|
const onSelectModel = vi.fn()
|
||||||
|
const onSetOptions = vi.fn()
|
||||||
|
|
||||||
|
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: false, onOpenDetails, onSelectModel, onSetOptions })
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ }))
|
||||||
|
|
||||||
|
expect(onOpenDetails).toHaveBeenCalledOnce()
|
||||||
|
expect(onSelectModel).not.toHaveBeenCalled()
|
||||||
|
expect(onSetOptions).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('param fast: reports the toggle', () => {
|
it('param fast: reports the toggle', () => {
|
||||||
const onSetOptions = vi.fn()
|
const onSetOptions = vi.fn()
|
||||||
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
|
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ interface ModelEditSubmenuProps {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
/** This row's model id. */
|
/** This row's model id. */
|
||||||
model: string
|
model: string
|
||||||
|
/** Read-only details; the owner handles opening without changing selection. */
|
||||||
|
onOpenDetails?: () => void
|
||||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||||
onSelectModel: (model: string) => Promise<boolean | void> | void
|
onSelectModel: (model: string) => Promise<boolean | void> | void
|
||||||
/** Report an option change. This submenu is PURE: it never writes to a
|
/** Report an option change. This submenu is PURE: it never writes to a
|
||||||
@@ -107,6 +109,7 @@ function ModelEditSubmenuBody({
|
|||||||
effort,
|
effort,
|
||||||
fastControl,
|
fastControl,
|
||||||
isActive,
|
isActive,
|
||||||
|
onOpenDetails,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
onSetOptions,
|
onSetOptions,
|
||||||
reasoning
|
reasoning
|
||||||
@@ -140,11 +143,19 @@ function ModelEditSubmenuBody({
|
|||||||
const hasFast = fastControl.kind !== 'none'
|
const hasFast = fastControl.kind !== 'none'
|
||||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||||
|
|
||||||
return !hasFast && !reasoning ? (
|
return !hasFast && !reasoning && !onOpenDetails ? (
|
||||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
{onOpenDetails ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={onOpenDetails}>
|
||||||
|
{copy.comboDetails}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{hasFast || reasoning ? <DropdownMenuSeparator className="mx-0" /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hasFast || reasoning ? <DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel> : null}
|
||||||
{showThinkingToggle ? (
|
{showThinkingToggle ? (
|
||||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||||
{copy.thinking}
|
{copy.thinking}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('ShareControls', () => {
|
|||||||
const dialog = await screen.findByRole('dialog')
|
const dialog = await screen.findByRole('dialog')
|
||||||
const input = within(dialog).getByRole('textbox')
|
const input = within(dialog).getByRole('textbox')
|
||||||
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle' }))
|
||||||
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||||
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { AiturkCliStatus } from '@/global'
|
||||||
|
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||||
|
import { aiturkLoginScope } from '@/lib/aiturk-provider'
|
||||||
|
import { Loader2 } from '@/lib/icons'
|
||||||
|
import { notify } from '@/store/notifications'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'starting' | 'waiting' | 'question' | 'replacing'
|
||||||
|
|
||||||
|
interface Waiting {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginCopy = ReturnType<typeof useAiturkCopy>
|
||||||
|
|
||||||
|
function errorCode(error: unknown): string {
|
||||||
|
// The main process only ever throws fixed codes ("aiturk-login:<code>").
|
||||||
|
return /aiturk-login:([a-z-]+)/.exec(error instanceof Error ? error.message : String(error))?.[1] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(code: string, copy: LoginCopy): string {
|
||||||
|
switch (code) {
|
||||||
|
case 'rate-limited':
|
||||||
|
return copy.login.rateLimited
|
||||||
|
|
||||||
|
case 'browser-failed':
|
||||||
|
return copy.login.browserFailed
|
||||||
|
|
||||||
|
case 'no-models':
|
||||||
|
return copy.noModels
|
||||||
|
|
||||||
|
case 'provider-failed':
|
||||||
|
|
||||||
|
case 'invalid-response':
|
||||||
|
return copy.failed
|
||||||
|
|
||||||
|
default:
|
||||||
|
return copy.login.unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliText(cli: AiturkCliStatus, copy: LoginCopy): string {
|
||||||
|
switch (cli) {
|
||||||
|
case 'ok':
|
||||||
|
return copy.login.cliOk
|
||||||
|
|
||||||
|
case 'outdated':
|
||||||
|
return copy.login.cliOutdated
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
|
||||||
|
case 'unknown-account':
|
||||||
|
return copy.login.cliFailed
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 'missing' stays silent; 'other-account' is asked as a question.
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-click TurkServis sign-in, shared by onboarding and Settings.
|
||||||
|
*
|
||||||
|
* The API keys never reach this component: the main process shows us only the
|
||||||
|
* short code, then reports `connected` once it has saved the provider and
|
||||||
|
* handed the AITURK CLI its own key.
|
||||||
|
*/
|
||||||
|
export function useAiturkLogin(onConnected: () => Promise<void> | void) {
|
||||||
|
const copy = useAiturkCopy()
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle')
|
||||||
|
const [waiting, setWaiting] = useState<null | Waiting>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [info, setInfo] = useState('')
|
||||||
|
const live = useRef(false)
|
||||||
|
const mounted = useRef(true)
|
||||||
|
const onConnectedRef = useRef(onConnected)
|
||||||
|
const copyRef = useRef(copy)
|
||||||
|
|
||||||
|
onConnectedRef.current = onConnected
|
||||||
|
copyRef.current = copy
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- mount flag + in-flight login flag, not an atom mirror
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted.current = false
|
||||||
|
|
||||||
|
// Leaving mid-login: revoke the code and drop anything main still holds.
|
||||||
|
if (live.current) {
|
||||||
|
live.current = false
|
||||||
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finish = useCallback(async (cli: AiturkCliStatus) => {
|
||||||
|
live.current = false
|
||||||
|
const text = cliText(cli, copyRef.current)
|
||||||
|
|
||||||
|
if (mounted.current) {
|
||||||
|
setWaiting(null)
|
||||||
|
setPhase('idle')
|
||||||
|
setInfo(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onConnectedRef.current()
|
||||||
|
} catch {
|
||||||
|
// The provider is saved; a failed refresh is the caller's concern.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onboarding closes this panel as soon as it is connected; keep the CLI
|
||||||
|
// note visible as a toast in that case.
|
||||||
|
if (!mounted.current && text) {
|
||||||
|
notify({ kind: cli === 'ok' ? 'success' : 'info', message: text })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- clears the in-flight login flag when polling ends
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'waiting') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopped = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
const bridge = window.hermesDesktop?.aiturkLogin
|
||||||
|
|
||||||
|
if (stopped || !bridge) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await bridge.poll()
|
||||||
|
} catch {
|
||||||
|
result = { state: 'pending' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.state === 'pending') {
|
||||||
|
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.state === 'connected') {
|
||||||
|
if (result.cli === 'other-account') {
|
||||||
|
setWaiting(null)
|
||||||
|
setPhase('question')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await finish(result.cli)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live.current = false
|
||||||
|
setWaiting(null)
|
||||||
|
setPhase('idle')
|
||||||
|
setError(result.state === 'expired' ? copyRef.current.login.expired : errorText(result.error, copyRef.current))
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => void tick(), POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [phase, finish])
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
const bridge = window.hermesDesktop?.aiturkLogin
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setInfo('')
|
||||||
|
|
||||||
|
if (!bridge) {
|
||||||
|
setError(copyRef.current.login.unsupported)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('starting')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = await bridge.start(aiturkLoginScope())
|
||||||
|
|
||||||
|
if (!mounted.current) {
|
||||||
|
void bridge.cancel().catch(() => {})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live.current = true
|
||||||
|
setWaiting({ userCode: started.userCode, verificationUri: started.verificationUri })
|
||||||
|
setPhase('waiting')
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) {
|
||||||
|
setPhase('idle')
|
||||||
|
setError(errorText(errorCode(err), copyRef.current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
live.current = false
|
||||||
|
setWaiting(null)
|
||||||
|
setPhase('idle')
|
||||||
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reopen = useCallback(() => {
|
||||||
|
if (waiting) {
|
||||||
|
void window.hermesDesktop?.openExternal(waiting.verificationUri).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [waiting])
|
||||||
|
|
||||||
|
const replaceCli = useCallback(async () => {
|
||||||
|
setPhase('replacing')
|
||||||
|
|
||||||
|
let cli: AiturkCliStatus = 'failed'
|
||||||
|
|
||||||
|
try {
|
||||||
|
cli = (await window.hermesDesktop?.aiturkLogin?.overwriteCli())?.cli ?? 'failed'
|
||||||
|
} catch {
|
||||||
|
cli = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
await finish(cli === 'other-account' ? 'failed' : cli)
|
||||||
|
}, [finish])
|
||||||
|
|
||||||
|
const keepCli = useCallback(async () => {
|
||||||
|
void window.hermesDesktop?.aiturkLogin?.cancel().catch(() => {})
|
||||||
|
await finish('missing')
|
||||||
|
}, [finish])
|
||||||
|
|
||||||
|
return { phase, waiting, error, info, start, cancel, reopen, replaceCli, keepCli }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiturkLoginProps {
|
||||||
|
/** Runs once the TurkServis provider is saved (refresh lists, advance onboarding…). */
|
||||||
|
onConnected: () => Promise<void> | void
|
||||||
|
/** Idle button label; defaults to "Giriş yap". */
|
||||||
|
label?: string
|
||||||
|
variant?: 'default' | 'outline'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiturkLogin({ onConnected, label, variant = 'default', disabled }: AiturkLoginProps) {
|
||||||
|
const copy = useAiturkCopy()
|
||||||
|
const login = useAiturkLogin(onConnected)
|
||||||
|
|
||||||
|
if (login.phase === 'waiting' && login.waiting) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3" data-testid="aiturk-login-code">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{copy.login.codeLabel}</div>
|
||||||
|
<output aria-label={copy.login.codeLabel} className="block font-mono text-3xl font-semibold tracking-[0.3em]">
|
||||||
|
{login.waiting.userCode}
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.login.confirmHint}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={login.reopen} size="sm" variant="outline">
|
||||||
|
{copy.login.reopen}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={login.cancel} size="sm" variant="ghost">
|
||||||
|
{copy.login.cancel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (login.phase === 'question' || login.phase === 'replacing') {
|
||||||
|
const replacing = login.phase === 'replacing'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3" role="group">
|
||||||
|
<p className="text-sm">{copy.login.cliOtherAccount}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={replacing} onClick={() => void login.replaceCli()} size="sm">
|
||||||
|
{replacing ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{copy.login.cliReplace}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={replacing} onClick={() => void login.keepCli()} size="sm" variant="outline">
|
||||||
|
{copy.login.cliKeep}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const starting = login.phase === 'starting'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div>
|
||||||
|
<Button disabled={disabled || starting} onClick={() => void login.start()} variant={variant}>
|
||||||
|
{starting ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{starting ? copy.login.starting : (label ?? copy.login.signIn)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{login.error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{login.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{login.info ? (
|
||||||
|
<p className="text-sm text-muted-foreground" role="status">
|
||||||
|
{login.info}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||||
@@ -9,7 +9,12 @@ import { AiturkProvider } from './aiturk-provider'
|
|||||||
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
vi.mock('@/hermes', () => ({ saveCustomEndpoint: vi.fn(), validateCustomEndpoint: vi.fn() }))
|
||||||
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
vi.mock('./flow', () => ({ DocsLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a> }))
|
||||||
|
|
||||||
afterEach(() => { cleanup(); vi.resetAllMocks() })
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.useRealTimers()
|
||||||
|
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||||
|
})
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const onConnected = vi.fn().mockResolvedValue(undefined)
|
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||||
@@ -17,6 +22,7 @@ function setup() {
|
|||||||
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
const input = screen.getByLabelText('TurkServis API anahtarı') as HTMLInputElement
|
||||||
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
fireEvent.change(input, { target: { value: 'test-key-never-real' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||||
|
|
||||||
return { input, onConnected }
|
return { input, onConnected }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +53,163 @@ describe('TurkServis provider setup', () => {
|
|||||||
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const URI = 'https://turkservis.online/account/aiturk/device-login/0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||||
|
|
||||||
|
function setupLogin(polls: unknown[]) {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
const bridge = {
|
||||||
|
start: vi.fn().mockResolvedValue({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: Date.now() + 600_000 }),
|
||||||
|
poll: vi.fn(async () => polls.shift() ?? { state: 'pending' }),
|
||||||
|
cancel: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
overwriteCli: vi.fn().mockResolvedValue({ cli: 'ok' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { aiturkLogin: bridge, openExternal }
|
||||||
|
const onConnected = vi.fn().mockResolvedValue(undefined)
|
||||||
|
render(<I18nProvider configClient={null}><AiturkProvider onConnected={onConnected} /></I18nProvider>)
|
||||||
|
|
||||||
|
return { bridge, onConnected, openExternal }
|
||||||
|
}
|
||||||
|
|
||||||
|
const click = async (name: string) => {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name }))
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () =>
|
||||||
|
act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TurkServis one-click sign-in', () => {
|
||||||
|
it('shows the code, lets the user reopen the page and polls every 3 seconds', async () => {
|
||||||
|
const { bridge, onConnected, openExternal } = setupLogin([{ state: 'pending' }, { state: 'pending' }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
|
||||||
|
expect(bridge.start).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByLabelText('Giriş kodu').textContent).toBe('ABCD2345')
|
||||||
|
expect(screen.getByText('Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.')).toBeTruthy()
|
||||||
|
expect(bridge.poll).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await tick()
|
||||||
|
expect(bridge.poll).toHaveBeenCalledTimes(1)
|
||||||
|
await tick()
|
||||||
|
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await click('Sayfayı yeniden aç')
|
||||||
|
expect(openExternal).toHaveBeenCalledWith(URI)
|
||||||
|
expect(onConnected).not.toHaveBeenCalled()
|
||||||
|
// No key ever reaches the renderer through this flow.
|
||||||
|
expect(saveCustomEndpoint).not.toHaveBeenCalled()
|
||||||
|
expect(validateCustomEndpoint).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConnected once the main process reports the provider connected', async () => {
|
||||||
|
const { bridge, onConnected } = setupLogin([{ state: 'pending' }, { state: 'connected', cli: 'ok' }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await tick()
|
||||||
|
expect(onConnected).not.toHaveBeenCalled()
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(onConnected).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||||
|
expect(screen.queryByLabelText('Giriş kodu')).toBeNull()
|
||||||
|
await tick()
|
||||||
|
expect(bridge.poll).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['missing', ''],
|
||||||
|
['outdated', 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.'],
|
||||||
|
['failed', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'],
|
||||||
|
['unknown-account', 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.']
|
||||||
|
])('connects and explains CLI result %s', async (cli, text) => {
|
||||||
|
const { onConnected } = setupLogin([{ state: 'connected', cli }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(onConnected).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.queryByRole('status')?.textContent ?? '').toBe(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks before replacing another CLI account, then replaces on confirm', async () => {
|
||||||
|
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(screen.getByText('AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?')).toBeTruthy()
|
||||||
|
expect(onConnected).not.toHaveBeenCalled()
|
||||||
|
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await click('Değiştir')
|
||||||
|
expect(bridge.overwriteCli).toHaveBeenCalledOnce()
|
||||||
|
expect(onConnected).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('status').textContent).toBe('AITURK CLI de bu hesapla hazır.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the other CLI account alone when the user says so', async () => {
|
||||||
|
const { bridge, onConnected } = setupLogin([{ state: 'connected', cli: 'other-account' }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await tick()
|
||||||
|
await click('Olduğu gibi bırak')
|
||||||
|
|
||||||
|
expect(bridge.overwriteCli).not.toHaveBeenCalled()
|
||||||
|
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||||
|
expect(onConnected).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels on Vazgeç and returns to the sign-in button', async () => {
|
||||||
|
const { bridge, onConnected } = setupLogin([])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await click('Vazgeç')
|
||||||
|
|
||||||
|
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||||
|
expect(screen.getByRole('button', { name: 'Giriş yap' })).toBeTruthy()
|
||||||
|
await tick()
|
||||||
|
expect(bridge.poll).not.toHaveBeenCalled()
|
||||||
|
expect(onConnected).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an expired code and a failed start without connecting', async () => {
|
||||||
|
const { bridge, onConnected } = setupLogin([{ state: 'expired' }])
|
||||||
|
await click('Giriş yap')
|
||||||
|
await tick()
|
||||||
|
expect(screen.getByRole('alert').textContent).toBe('Giriş kodunun süresi doldu. Yeniden deneyin.')
|
||||||
|
|
||||||
|
bridge.start.mockRejectedValueOnce(
|
||||||
|
new Error("Error invoking remote method 'aiturk:login:start': AiturkLoginError: aiturk-login:rate-limited")
|
||||||
|
)
|
||||||
|
await click('Giriş yap')
|
||||||
|
expect(screen.getByRole('alert').textContent).toBe('Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.')
|
||||||
|
expect(onConnected).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokes a pending login when the panel goes away', async () => {
|
||||||
|
const { bridge } = setupLogin([])
|
||||||
|
await click('Giriş yap')
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
expect(bridge.cancel).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still connects with a pasted API key', async () => {
|
||||||
|
vi.mocked(validateCustomEndpoint).mockResolvedValue({ ok: true, reachable: true, models: ['account/model-a'], message: '' })
|
||||||
|
vi.mocked(saveCustomEndpoint).mockResolvedValue({ id: 'turkservis', endpoints: [], current: { base_url: 'https://ai.turkservis.online/v1', model: 'account/model-a', provider: 'turkservis' }, ok: true })
|
||||||
|
const { bridge, onConnected } = setupLogin([])
|
||||||
|
vi.useRealTimers()
|
||||||
|
|
||||||
|
expect(screen.getByText('ya da API anahtarı yapıştırın')).toBeTruthy()
|
||||||
|
fireEvent.change(screen.getByLabelText('TurkServis API anahtarı'), { target: { value: 'test-key-never-real' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Bağlan' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(onConnected).toHaveBeenCalledOnce())
|
||||||
|
expect(saveCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'test-key-never-real', model: 'account/model-a' }))
|
||||||
|
expect(bridge.start).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { AiturkLogin } from '@/components/aiturk-login'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
import { saveCustomEndpoint, validateCustomEndpoint } from '@/hermes'
|
||||||
@@ -18,18 +19,22 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (busy || !key.trim()) return
|
if (busy || !key.trim()) {return}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Discovery goes through the connected backend's existing credential
|
// Discovery goes through the connected backend's existing credential
|
||||||
// boundary. Do not ship a shared key or hard-code a model catalog.
|
// boundary. Do not ship a shared key or hard-code a model catalog.
|
||||||
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
const checked = await validateCustomEndpoint(aiturkEndpoint(key))
|
||||||
if (!checked.ok) throw new Error(checked.message || copy.failed)
|
|
||||||
|
if (!checked.ok) {throw new Error(checked.message || copy.failed)}
|
||||||
const model = checked.models[0]
|
const model = checked.models[0]
|
||||||
if (!model) throw new Error(copy.noModels)
|
|
||||||
|
if (!model) {throw new Error(copy.noModels)}
|
||||||
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
const saved = await saveCustomEndpoint(aiturkEndpoint(key, model, checked.models))
|
||||||
if (saved.ok === false) throw new Error(copy.failed)
|
|
||||||
|
if (saved.ok === false) {throw new Error(copy.failed)}
|
||||||
setKey('')
|
setKey('')
|
||||||
await onConnected()
|
await onConnected()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,16 +50,18 @@ export function AiturkProvider({ onConnected }: { onConnected: () => Promise<voi
|
|||||||
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
<h3 className="font-semibold">TurkServis · AITURK</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{copy.pitch}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<AiturkLogin disabled={busy} onConnected={onConnected} />
|
||||||
|
<div className="text-xs text-muted-foreground">{copy.login.orPasteKey}</div>
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
{copy.key}
|
{copy.key}
|
||||||
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
<Input autoComplete="off" disabled={busy} onChange={e => setKey(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) void connect() }}
|
onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) {void connect()} }}
|
||||||
placeholder={copy.key} type="password" value={key} />
|
placeholder={copy.key} type="password" value={key} />
|
||||||
</label>
|
</label>
|
||||||
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
<DocsLink href={AITURK_ACCOUNT_URL}>{copy.getKey}</DocsLink>
|
||||||
<Button disabled={busy || !key.trim()} onClick={() => void connect()}>
|
<Button disabled={busy || !key.trim()} onClick={() => void connect()} variant="outline">
|
||||||
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
{busy ? <Loader2 className="animate-spin" /> : null}{busy ? t.common.connecting : t.common.connect}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared'
|
|||||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||||
|
|
||||||
import type { PoolLimits } from '../electron/pool-limits'
|
import type { PoolLimits } from '../electron/pool-limits'
|
||||||
|
import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types'
|
||||||
|
|
||||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||||
import type {
|
import type {
|
||||||
@@ -17,6 +18,7 @@ export {}
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
hermesDesktop: {
|
hermesDesktop: {
|
||||||
|
codexLink?: { control: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse> }
|
||||||
setUiLanguage?: (locale: string) => void
|
setUiLanguage?: (locale: string) => void
|
||||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||||
@@ -323,6 +325,18 @@ declare global {
|
|||||||
setDisableF12?: (blocked: boolean) => void
|
setDisableF12?: (blocked: boolean) => void
|
||||||
setPreviewShortcutActive?: (active: boolean) => void
|
setPreviewShortcutActive?: (active: boolean) => void
|
||||||
openExternal: (url: string) => Promise<void>
|
openExternal: (url: string) => Promise<void>
|
||||||
|
/** One-click TurkServis sign-in (electron/aiturk-login.ts). API keys stay
|
||||||
|
* in the main process; only the short code and a status come back. */
|
||||||
|
aiturkLogin?: {
|
||||||
|
start: (scope?: { connectionId?: string; profile?: string }) => Promise<AiturkLoginStart>
|
||||||
|
poll: () => Promise<AiturkLoginPoll>
|
||||||
|
cancel: () => Promise<{ ok: true }>
|
||||||
|
overwriteCli: () => Promise<{ cli: AiturkCliStatus }>
|
||||||
|
}
|
||||||
|
/** "Telefon bağlantısı": opens the pairing window (electron/mobile-host.mjs). Windows only. */
|
||||||
|
aiturkMobile?: {
|
||||||
|
open: () => Promise<{ ok: true }>
|
||||||
|
}
|
||||||
/** One-shot loopback callback listener for MCP OAuth against remote
|
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||||
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,
|
||||||
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
* pass redirectUri as client_redirect_uri to mcp.servers.oauth.start,
|
||||||
@@ -1212,6 +1226,20 @@ export type DesktopBootstrapEvent =
|
|||||||
docsUrl: string
|
docsUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiturkCliStatus = 'ok' | 'missing' | 'outdated' | 'other-account' | 'unknown-account' | 'failed'
|
||||||
|
|
||||||
|
export interface AiturkLoginStart {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiturkLoginPoll =
|
||||||
|
| { state: 'pending' }
|
||||||
|
| { state: 'expired' }
|
||||||
|
| { state: 'connected'; cli: AiturkCliStatus }
|
||||||
|
| { state: 'error'; error: string }
|
||||||
|
|
||||||
export interface HermesApiRequest {
|
export interface HermesApiRequest {
|
||||||
path: string
|
path: string
|
||||||
method?: string
|
method?: string
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ const en = {
|
|||||||
getKey: 'My account and API keys',
|
getKey: 'My account and API keys',
|
||||||
noModels: 'No models are available for this key.',
|
noModels: 'No models are available for this key.',
|
||||||
failed: 'Could not connect to TurkServis.',
|
failed: 'Could not connect to TurkServis.',
|
||||||
|
login: {
|
||||||
|
signIn: 'Sign in',
|
||||||
|
settingsSignIn: 'Sign in to AITURK',
|
||||||
|
starting: 'Starting sign-in…',
|
||||||
|
codeLabel: 'Sign-in code',
|
||||||
|
confirmHint: 'Approve if the code on the page opened in your browser matches this one.',
|
||||||
|
reopen: 'Reopen the page',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
orPasteKey: 'or paste an API key',
|
||||||
|
connected: 'Your TurkServis account is connected.',
|
||||||
|
expired: 'The sign-in code expired. Try again.',
|
||||||
|
rateLimited: 'Too many attempts. Try again in a little while.',
|
||||||
|
unavailable: 'Could not start sign-in. Check your internet connection and try again.',
|
||||||
|
browserFailed: 'Could not open the browser. Try again.',
|
||||||
|
unsupported: 'One-click sign-in is not available here. Paste an API key instead.',
|
||||||
|
cliOk: 'AITURK CLI is ready with this account too.',
|
||||||
|
cliOutdated: 'After AITURK CLI is updated, sign in again to connect it automatically.',
|
||||||
|
cliOtherAccount: 'AITURK CLI has a different account saved. Replace it with this account?',
|
||||||
|
cliReplace: 'Replace',
|
||||||
|
cliKeep: 'Leave as is',
|
||||||
|
cliFailed: 'AITURK CLI could not be connected; run `aiturk login` in a terminal.'
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
title: 'Custom endpoints',
|
title: 'Custom endpoints',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
@@ -64,6 +86,10 @@ const en = {
|
|||||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
||||||
confirmDelete: (name: string) => `Delete ${name}?`
|
confirmDelete: (name: string) => `Delete ${name}?`
|
||||||
},
|
},
|
||||||
|
mobile: {
|
||||||
|
nav: 'Phone connection',
|
||||||
|
openFailed: 'Could not open the phone connection window.'
|
||||||
|
},
|
||||||
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
||||||
downloads: 'Official downloads and updates',
|
downloads: 'Official downloads and updates',
|
||||||
source: 'Source code and license',
|
source: 'Source code and license',
|
||||||
@@ -79,6 +105,28 @@ const tr: typeof en = {
|
|||||||
getKey: 'Hesabım ve API anahtarlarım',
|
getKey: 'Hesabım ve API anahtarlarım',
|
||||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
failed: 'TurkServis bağlantısı kurulamadı.',
|
||||||
|
login: {
|
||||||
|
signIn: 'Giriş yap',
|
||||||
|
settingsSignIn: 'AITURK’e giriş yap',
|
||||||
|
starting: 'Giriş başlatılıyor…',
|
||||||
|
codeLabel: 'Giriş kodu',
|
||||||
|
confirmHint: 'Tarayıcıda açılan sayfadaki kod bununla aynıysa onaylayın.',
|
||||||
|
reopen: 'Sayfayı yeniden aç',
|
||||||
|
cancel: 'Vazgeç',
|
||||||
|
orPasteKey: 'ya da API anahtarı yapıştırın',
|
||||||
|
connected: 'TurkServis hesabınız bağlandı.',
|
||||||
|
expired: 'Giriş kodunun süresi doldu. Yeniden deneyin.',
|
||||||
|
rateLimited: 'Çok fazla deneme yapıldı. Biraz sonra yeniden deneyin.',
|
||||||
|
unavailable: 'Giriş başlatılamadı. İnternet bağlantınızı kontrol edip yeniden deneyin.',
|
||||||
|
browserFailed: 'Tarayıcı açılamadı. Yeniden deneyin.',
|
||||||
|
unsupported: 'Tek tıkla giriş burada kullanılamıyor. API anahtarı yapıştırın.',
|
||||||
|
cliOk: 'AITURK CLI de bu hesapla hazır.',
|
||||||
|
cliOutdated: 'AITURK CLI güncellenince kendiliğinden bağlanması için yeniden giriş yapın.',
|
||||||
|
cliOtherAccount: 'AITURK CLI’de başka bir hesap kayıtlı. Bu hesapla değiştirilsin mi?',
|
||||||
|
cliReplace: 'Değiştir',
|
||||||
|
cliKeep: 'Olduğu gibi bırak',
|
||||||
|
cliFailed: 'AITURK CLI bağlanamadı; terminalde `aiturk login` çalıştırın.'
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
title: 'Özel API bağlantıları',
|
title: 'Özel API bağlantıları',
|
||||||
active: 'Etkin',
|
active: 'Etkin',
|
||||||
@@ -113,6 +161,10 @@ const tr: typeof en = {
|
|||||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
||||||
confirmDelete: name => `${name} silinsin mi?`
|
confirmDelete: name => `${name} silinsin mi?`
|
||||||
},
|
},
|
||||||
|
mobile: {
|
||||||
|
nav: 'Telefon bağlantısı',
|
||||||
|
openFailed: 'Telefon bağlantısı penceresi açılamadı.'
|
||||||
|
},
|
||||||
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
||||||
downloads: 'Resmî indirmeler ve güncellemeler',
|
downloads: 'Resmî indirmeler ve güncellemeler',
|
||||||
source: 'Kaynak kodu ve lisans',
|
source: 'Kaynak kodu ve lisans',
|
||||||
|
|||||||
@@ -2235,6 +2235,7 @@ export const ar = defineLocale({
|
|||||||
fast: 'سريع'
|
fast: 'سريع'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'تفاصيل النموذج / المجموعة',
|
||||||
noOptions: 'لا توجد خيارات لهذا النموذج',
|
noOptions: 'لا توجد خيارات لهذا النموذج',
|
||||||
options: 'الخيارات',
|
options: 'الخيارات',
|
||||||
thinking: 'التفكير',
|
thinking: 'التفكير',
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useI18n } from './context'
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
title: 'Connect Codex',
|
||||||
|
intro: 'Use this IDE’s models and combos in Codex. Each chat keeps its own memory.',
|
||||||
|
enabled: 'Connect models to Codex',
|
||||||
|
enabledHelp: 'Enabled on first installation. Your choice is saved.',
|
||||||
|
compression: 'Automatic compaction',
|
||||||
|
compressionHelp:
|
||||||
|
'Checks the complete request before sending. Earlier summaries are retained as the available context allows.',
|
||||||
|
threshold: 'Compaction threshold (tokens)',
|
||||||
|
thresholdHelp:
|
||||||
|
'The effective threshold is lower when a model has less available input space. Output space is reserved too.',
|
||||||
|
invalidThreshold: 'Enter a positive whole number.',
|
||||||
|
save: 'Save',
|
||||||
|
source: 'Connected account',
|
||||||
|
noSource: 'No authorized model source is available.',
|
||||||
|
sync: 'Refresh models',
|
||||||
|
refresh: 'Refresh status',
|
||||||
|
loading: 'Reading saved settings…',
|
||||||
|
unavailable: 'The local Codex connection service is unavailable. Check the AITURK installation.',
|
||||||
|
failed: 'Could not read the latest state.',
|
||||||
|
unknown: 'The result is not confirmed. Check this same operation before making another change.',
|
||||||
|
reconcile: 'Check operation',
|
||||||
|
conflict: 'Settings changed elsewhere. The latest saved state has been loaded.',
|
||||||
|
saved: 'Settings saved.',
|
||||||
|
accepted: 'Operation accepted. Its progress appears below.',
|
||||||
|
chats: 'Chat memory',
|
||||||
|
empty: 'No managed Codex chat is currently visible.',
|
||||||
|
compact: 'Compact now',
|
||||||
|
summaries: 'Saved summaries',
|
||||||
|
tokens: 'tokens',
|
||||||
|
unmeasured: 'Not measured yet',
|
||||||
|
current: 'Current input',
|
||||||
|
estimated: 'Estimate',
|
||||||
|
upperBound: 'Verified upper bound',
|
||||||
|
limit: 'Effective threshold',
|
||||||
|
restart: 'The next Codex launch will use the new runtime. Existing chats keep their current process.',
|
||||||
|
activation: {
|
||||||
|
disabled: 'Disabled',
|
||||||
|
pending_runtime_acceptance: 'Runtime verification pending',
|
||||||
|
ready: 'Ready',
|
||||||
|
restart_required: 'Restart required',
|
||||||
|
conflict: 'Configuration conflict',
|
||||||
|
recovery_pending: 'Recovering the saved state'
|
||||||
|
},
|
||||||
|
admission: {
|
||||||
|
open: 'External models available',
|
||||||
|
closed: 'External models unavailable',
|
||||||
|
draining: 'Finishing active work',
|
||||||
|
recovery_required: 'Operation recovery required'
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
accepted: 'Accepted',
|
||||||
|
prepared: 'Prepared',
|
||||||
|
running: 'Running',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
indeterminate: 'Result unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr: typeof en = {
|
||||||
|
title: 'Codex Bağlama',
|
||||||
|
intro: 'Bu IDE’deki modelleri ve comboları Codex’te kullanın. Her sohbet kendi hafızasını tutar.',
|
||||||
|
enabled: 'Modelleri Codex’e bağla',
|
||||||
|
enabledHelp: 'İlk kurulumda açıktır. Değiştirdiğiniz tercih kaydedilir.',
|
||||||
|
compression: 'Otomatik sıkıştırma',
|
||||||
|
compressionHelp:
|
||||||
|
'Gönderilmeden önce isteğin tamamı kontrol edilir. Önceki özetler kullanılabilir bağlamın izin verdiği ölçüde korunur.',
|
||||||
|
threshold: 'Sıkıştırma eşiği (token)',
|
||||||
|
thresholdHelp:
|
||||||
|
'Modelin kullanılabilir giriş alanı daha küçükse etkin eşik düşer. Yanıt için gereken alan da ayrılır.',
|
||||||
|
invalidThreshold: 'Pozitif bir tam sayı girin.',
|
||||||
|
save: 'Kaydet',
|
||||||
|
source: 'Bağlı hesap',
|
||||||
|
noSource: 'Yetkili model kaynağı bulunamadı.',
|
||||||
|
sync: 'Modelleri yenile',
|
||||||
|
refresh: 'Durumu yenile',
|
||||||
|
loading: 'Kayıtlı ayarlar okunuyor…',
|
||||||
|
unavailable: 'Yerel Codex bağlantı hizmetine ulaşılamıyor. AITURK kurulumunu kontrol edin.',
|
||||||
|
failed: 'Güncel durum okunamadı.',
|
||||||
|
unknown: 'İşlemin sonucu henüz doğrulanmadı. Yeni değişiklikten önce aynı işlemin durumunu kontrol edin.',
|
||||||
|
reconcile: 'İşlemi kontrol et',
|
||||||
|
conflict: 'Ayarlar başka bir yerde değişti. Güncel kayıtlı ayarlar yüklendi.',
|
||||||
|
saved: 'Ayarlar kaydedildi.',
|
||||||
|
accepted: 'İşlem kabul edildi. İlerlemesi aşağıda gösterilir.',
|
||||||
|
chats: 'Sohbet hafızası',
|
||||||
|
empty: 'Şu anda görünen yönetilen Codex sohbeti yok.',
|
||||||
|
compact: 'Şimdi sıkıştır',
|
||||||
|
summaries: 'Kayıtlı özetler',
|
||||||
|
tokens: 'token',
|
||||||
|
unmeasured: 'Henüz ölçülmedi',
|
||||||
|
current: 'Mevcut giriş',
|
||||||
|
estimated: 'Tahmin',
|
||||||
|
upperBound: 'Doğrulanmış üst sınır',
|
||||||
|
limit: 'Etkin eşik',
|
||||||
|
restart: 'Yeni runtime sonraki Codex açılışında kullanılacak. Açık sohbetler mevcut süreçleriyle devam eder.',
|
||||||
|
activation: {
|
||||||
|
disabled: 'Kapalı',
|
||||||
|
pending_runtime_acceptance: 'Runtime doğrulaması bekleniyor',
|
||||||
|
ready: 'Hazır',
|
||||||
|
restart_required: 'Yeniden açılış gerekli',
|
||||||
|
conflict: 'Ayar çakışması',
|
||||||
|
recovery_pending: 'Kayıtlı durum kurtarılıyor'
|
||||||
|
},
|
||||||
|
admission: {
|
||||||
|
open: 'Harici modeller kullanılabilir',
|
||||||
|
closed: 'Harici modeller kullanılamıyor',
|
||||||
|
draining: 'Etkin işler bitiriliyor',
|
||||||
|
recovery_required: 'İşlem durumu uzlaştırılmalı'
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
accepted: 'Kabul edildi',
|
||||||
|
prepared: 'Hazırlandı',
|
||||||
|
running: 'Çalışıyor',
|
||||||
|
completed: 'Tamamlandı',
|
||||||
|
failed: 'Başarısız',
|
||||||
|
indeterminate: 'Sonuç belirsiz'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCodexLinkCopy() {
|
||||||
|
return useI18n().locale === 'tr' ? tr : en
|
||||||
|
}
|
||||||
@@ -2999,6 +2999,7 @@ export const en: Translations = {
|
|||||||
fast: 'Fast'
|
fast: 'Fast'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Model / combo details',
|
||||||
noOptions: 'No options for this model',
|
noOptions: 'No options for this model',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
|
|||||||
@@ -2624,6 +2624,7 @@ export const ja = defineLocale({
|
|||||||
fast: '高速'
|
fast: '高速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'モデル / コンボの詳細',
|
||||||
noOptions: 'このモデルにはオプションがありません',
|
noOptions: 'このモデルにはオプションがありません',
|
||||||
options: 'オプション',
|
options: 'オプション',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -3022,6 +3022,7 @@ export const ru = defineLocale({
|
|||||||
fast: 'Быстрая'
|
fast: 'Быстрая'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Сведения о модели / комбинации',
|
||||||
noOptions: 'Для этой модели нет опций',
|
noOptions: 'Для этой модели нет опций',
|
||||||
options: 'Опции',
|
options: 'Опции',
|
||||||
thinking: 'Размышление',
|
thinking: 'Размышление',
|
||||||
|
|||||||
@@ -1281,6 +1281,7 @@ export const trOverrides: TranslationOverrides = {
|
|||||||
fast: 'Hızlı'
|
fast: 'Hızlı'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Model / combo ayrıntıları',
|
||||||
noOptions: 'Bu model için seçenek yok',
|
noOptions: 'Bu model için seçenek yok',
|
||||||
options: 'Seçenekler',
|
options: 'Seçenekler',
|
||||||
thinking: 'Düşünme',
|
thinking: 'Düşünme',
|
||||||
|
|||||||
@@ -2546,6 +2546,7 @@ export interface Translations {
|
|||||||
fast: string
|
fast: string
|
||||||
}
|
}
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: string
|
||||||
noOptions: string
|
noOptions: string
|
||||||
options: string
|
options: string
|
||||||
thinking: string
|
thinking: string
|
||||||
|
|||||||
@@ -2523,6 +2523,7 @@ export const zhHant = defineLocale({
|
|||||||
fast: '快速'
|
fast: '快速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: '模型 / 組合詳細資料',
|
||||||
noOptions: '此模型沒有可用選項',
|
noOptions: '此模型沒有可用選項',
|
||||||
options: '選項',
|
options: '選項',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -3147,6 +3147,7 @@ export const zh: Translations = {
|
|||||||
fast: '快速'
|
fast: '快速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: '模型 / 组合详情',
|
||||||
noOptions: '此模型没有可用选项',
|
noOptions: '此模型没有可用选项',
|
||||||
options: '选项',
|
options: '选项',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import { connectionScoped, profileScoped } from '@/api/client'
|
||||||
import type { CustomEndpointUpdate } from '@/types/hermes'
|
import type { CustomEndpointUpdate } from '@/types/hermes'
|
||||||
|
|
||||||
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
|
||||||
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
|
||||||
|
|
||||||
|
/** Where the main process should save the provider after a one-click sign-in:
|
||||||
|
* the same (connection, profile) the renderer's saveCustomEndpoint targets. */
|
||||||
|
export function aiturkLoginScope(): { connectionId?: string; profile?: string } {
|
||||||
|
return { ...connectionScoped(), ...profileScoped() }
|
||||||
|
}
|
||||||
|
|
||||||
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
|
||||||
return {
|
return {
|
||||||
id: 'turkservis',
|
id: 'turkservis',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { turkservisComboDetailsUrl } from './turkservis-combo-details'
|
||||||
|
|
||||||
|
describe('TurkServis read-only details link', () => {
|
||||||
|
it('preserves the exact model and drops API query parameters', () => {
|
||||||
|
const result = turkservisComboDetailsUrl('https://ai.turkservis.online/v1?key=private', '9router-Team & A')!
|
||||||
|
|
||||||
|
expect(new URL(result).searchParams.get('model')).toBe('9router-Team & A')
|
||||||
|
expect(new URL(result).origin).toBe('https://turkservis.online')
|
||||||
|
expect(result).not.toContain('private')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([undefined, '', 'invalid', 'http://ai.turkservis.online/v1',
|
||||||
|
'https://ai.turkservis.online.evil.test/v1', 'https://other.test/v1',
|
||||||
|
'https://user:secret@ai.turkservis.online/v1', 'https://ai.turkservis.online:9090/v1'
|
||||||
|
])('does not offer details for an unrelated or unsafe endpoint: %s', apiUrl => {
|
||||||
|
expect(turkservisComboDetailsUrl(apiUrl, 'Team')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing and oversized model identities', () => {
|
||||||
|
expect(turkservisComboDetailsUrl('https://turkservis.online/v1', '')).toBeNull()
|
||||||
|
expect(turkservisComboDetailsUrl('https://turkservis.online/v1', 'x'.repeat(129))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** Details use the member portal's own login; API credentials never enter a URL. */
|
||||||
|
export function turkservisComboDetailsUrl(apiUrl: string | undefined, model: string): string | null {
|
||||||
|
if (!apiUrl || !model || model.length > 128) {return null}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = new URL(apiUrl)
|
||||||
|
|
||||||
|
if (
|
||||||
|
endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.port ||
|
||||||
|
!['ai.turkservis.online', 'turkservis.online'].includes(endpoint.hostname)
|
||||||
|
) {return null}
|
||||||
|
|
||||||
|
const details = new URL('https://turkservis.online/router')
|
||||||
|
|
||||||
|
details.searchParams.set('model', model)
|
||||||
|
|
||||||
|
return details.href
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Synthetic, isolated visual acceptance fixture. Never contacts the installed manager.
|
||||||
|
import '../styles.css'
|
||||||
|
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import type { CodexLinkControlRequest, CodexLinkControlResponse, CodexLinkSettings as Settings } from '../../codex-link-control.types'
|
||||||
|
import { CodexLinkSettings } from '../app/settings/codex-link-settings'
|
||||||
|
import { I18nProvider } from '../i18n/context'
|
||||||
|
import { ThemeProvider } from '../themes/context'
|
||||||
|
|
||||||
|
let saved: Settings = { settings_revision: 1, enabled: true, compression_enabled: true, threshold_tokens: 250000, source_binding_handle: 'source-demo', compact_route_id: 'Compact', legacy_intent_imported: false, ownership_manifest_revision: 1, activation_state: 'ready' }
|
||||||
|
const budget = { target_id: 'target-demo', canonical_leaf_id: 'leaf-demo', limits_source: 'verified_adapter' as const, limits_revision: 'limits-1', tokenizer_id: 'tokenizer-demo', tokenizer_revision: '1', count_method: 'exact_tokenizer' as const, serialization_revision: '1', input_tokens: 85000, context_window_tokens: 100000, output_cap_tokens: 8000, safety_margin_tokens: 1000, max_input_tokens: null, max_output_tokens: 8000, request_bytes: 400000, max_request_bytes: 4000000, output_cap_enforcement: { method: 'request_field' as const, field: 'max_output_tokens' as const, value: 8000, proof_revision: '1', verified: true as const } }
|
||||||
|
|
||||||
|
const bridge: Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'> = { codexLink: { control: async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||||
|
const base = { contract_version: 'codex-link/1' as const, request_id: request.request_id, operation_id: request.operation_id }
|
||||||
|
|
||||||
|
if (request.kind === 'status_get') {return { ...base, kind: 'status_result', status: { settings_revision: saved.settings_revision, runtime_mode: 'managed', native_available: true, external_admission: 'open', needs_restart: false, catalog_state: 'fresh', active_turns: 0, uncertain_operations: 0, last_operation_id: null, error: null } }}
|
||||||
|
|
||||||
|
if (request.kind === 'context_views_get') {return { ...base, kind: 'context_views_result', settings_revision: saved.settings_revision, views: [{ view_handle: 'view-demo', can_compact: true, label: 'Proje sohbeti · örnek kayıt', checkpoint_revision: 4, current_model_label: '100 bin bağlamlı model', limiting_target_id: budget.target_id, target_budgets: [budget], active_summary_count: 3, summary_tokens: 20000, summary_token_measurement: { target_id: budget.target_id, tokenizer_id: budget.tokenizer_id, tokenizer_revision: budget.tokenizer_revision, count_method: budget.count_method }, last_operation_id: null, last_operation_state: null, last_operation_error: null, compact_attempts: [] }] }}
|
||||||
|
|
||||||
|
if (request.kind === 'settings_update') {saved = { ...saved, ...request.patch, settings_revision: saved.settings_revision + 1 }}
|
||||||
|
|
||||||
|
if (request.kind === 'catalog_sync' || request.kind === 'context_compact_view') {return { ...base, kind: 'control_accepted', settings_revision: saved.settings_revision, operation_state: 'accepted', error: null }}
|
||||||
|
|
||||||
|
return { ...base, kind: 'settings_result', settings: saved, available_sources: [{ source_binding_handle: 'source-demo', label: 'AITURK hesabı', authorized: true }] }
|
||||||
|
} } }
|
||||||
|
|
||||||
|
Object.assign(window, { hermesDesktop: bridge })
|
||||||
|
createRoot(document.getElementById('root')!).render(<I18nProvider configClient={null} initialLocale="tr"><ThemeProvider><main style={{ maxWidth: 820, margin: '0 auto' }}><CodexLinkSettings /></main></ThemeProvider></I18nProvider>)
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "build/electron-types"
|
"outDir": "build/electron-types"
|
||||||
},
|
},
|
||||||
"include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
"include": ["electron", "codex-link-control.ts", "codex-link-control.types.ts", "codex-link-control.schema.json", "codex-link-json.ts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
||||||
"exclude": ["src", "electron/**/*.e2e.mts"]
|
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="tr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Codex Bağlama kabul görünümü</title></head><body><div id="root"></div><script type="module" src="../src/visual-fixtures/codex-link-settings.tsx"></script></body></html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: [
|
||||||
|
'electron/mobile-*.test.ts',
|
||||||
|
'electron/first-run-setup*.test.ts',
|
||||||
|
'electron/primary-backend-startup.test.ts'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.bat text eol=crlf
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
android/.gradle/
|
||||||
|
android/local.properties
|
||||||
|
android/**/build/
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
test-results/
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# AITURK Mobil — Android geliştirme sürümü 0.1.4
|
||||||
|
|
||||||
|
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
|
||||||
|
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
|
||||||
|
Google Live ve gerçek telefon/ağ doğrulaması henüz yapılmadı. Canlı
|
||||||
|
turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi.
|
||||||
|
|
||||||
|
## Çalışan ve test edilen parçalar
|
||||||
|
|
||||||
|
- Capacitor 8.5.1 Android projesi; Türkçe erişilebilir kurulum ve yazılı kullanım.
|
||||||
|
- Tarayıcıda mevcut AITURK hesabıyla kısa kod onayı; uygulamaya parola girilmez.
|
||||||
|
Dar kapsamlı mobil oturum Windows/Android kasasında tutulur. Mevcut hesap API
|
||||||
|
anahtarı gelişmiş seçenek olarak korunur. Bu yeni giriş henüz canlıya dağıtılmadı.
|
||||||
|
Telefonun eşleşmesini hedef bilgisayar kendi cihaz tokenıyla onaylar.
|
||||||
|
- WebRTC DTLS/SCTP veri kanalı; büyük Unicode mesajları sınırlı parçalara bölme.
|
||||||
|
- Mevcut IDE `/api/sessions` ve JSON-RPC adaptörü. Konuşma ve seçili model
|
||||||
|
gönderimden önce doğrulanır. Model listesi icat edilmez.
|
||||||
|
- Görevden önce diske yazılan tekrar gönderim kaydı. Kabul sonucu belirsizse
|
||||||
|
ikinci kez çalıştırılmaz. Telefonun bekleyen gönderimi Android kasasında kalır.
|
||||||
|
- Gerçek IDE onay kimliği ve redakte edilmiş tam komutla yalnız `once`/`deny`.
|
||||||
|
Sürekli/genel izin, sudo/UAC veya ses modeli üzerinden onay yolu yoktur.
|
||||||
|
- Google anahtarı Windows `safeStorage`, hesap cihaz sırrı Android Keystore
|
||||||
|
AES-GCM kullanır. Android yedekleme ve cihazlar arası veri taşıma kapatılır.
|
||||||
|
- Anahtardan kısa ömürlü, model/araç/sistem talimatı kısıtlı Google Live token
|
||||||
|
üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu.
|
||||||
|
- Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder
|
||||||
|
düğmesi ve ayrı IDE durdur düğmesi vardır.
|
||||||
|
- Google kullanım bildirimleri ücretli liste tarifesiyle tahmin edilir; her
|
||||||
|
isteğin tekrar faturalandırılan geçmişi de sayılır. Ayrıntısı eksik tokenlar
|
||||||
|
için fiyat aralığı gösterilir. Bu fatura, kredi veya ücretsiz kota sorgusu değildir.
|
||||||
|
- Kullanıcı her görüşme için 1–10 dakika ve 0,01–10 USD tahmini durdurma sınırı
|
||||||
|
seçer (varsayılan 10 dakika / 0,25 USD). Üst tahmin sınıra ulaşırsa, süre
|
||||||
|
dolarsa veya 120 saniye kullanım bildirimi gelmezse yalnız Google bağlantısı
|
||||||
|
kapanır. Otomatik yeniden bağlanılmaz. Her yeni oturumda sayaç sıfırlanır;
|
||||||
|
bu günlük/aylık bütçe veya Google tarafında kesin harcama tavanı değildir.
|
||||||
|
|
||||||
|
## Geliştirme kurulumu
|
||||||
|
|
||||||
|
Node 22+, JDK 21 ve Android API 36 gerekir. Bu çalışma sırasında araçlar
|
||||||
|
`%LOCALAPPDATA%/aiturk-mobile-tools` altına kuruldu; kullanıcı sistem PATH'i
|
||||||
|
veya yönetici ayarları değiştirilmedi.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Bu klasörde; kök depodaki kurulumu etkilemeyen Android kurulumu
|
||||||
|
npm ci --workspaces=false --ignore-scripts
|
||||||
|
npm test --workspaces=false
|
||||||
|
npm run android:sync --workspaces=false
|
||||||
|
# JAVA_HOME ve ANDROID_HOME kendi araç yollarınızı göstermeli
|
||||||
|
.\android\gradlew.bat -p android assembleDebug assembleRelease lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
`app-debug.apk` geliştirici anahtarıyla imzalanır. `app-release-unsigned.apk`
|
||||||
|
üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim
|
||||||
|
imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı.
|
||||||
|
|
||||||
|
## Uyumlu IDE ve bağlantı servisi
|
||||||
|
|
||||||
|
Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin.
|
||||||
|
Derlenmiş IDE'yi `--aiturk-mobile` parametresiyle açın. IDE zaten açıksa parametre
|
||||||
|
mobil bağlantı penceresini açar; çalışmakta olan konuşma kapatılmaz.
|
||||||
|
Mobil pencere açılmadan uzaktan erişim etkinleşmez.
|
||||||
|
|
||||||
|
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
|
||||||
|
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
|
||||||
|
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
|
||||||
|
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayın. Bilgisayarda
|
||||||
|
“Telefon için QR oluştur” seçip telefonda kamerayla veya QR resmiyle okuyun.
|
||||||
|
4. Bilgisayarda “QR ile gelen telefonu getir” seçin; telefonun gösterdiği kısa
|
||||||
|
kodu girip açıkça onaylayın. Uzun kimliği elle yazmak gerekmez.
|
||||||
|
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
|
||||||
|
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
|
||||||
|
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
|
||||||
|
Veri/ücret bilgisini kabul ettikten sonra Google görüşmesini başlatın.
|
||||||
|
|
||||||
|
Gateway eş değişikliği `portal/routes_mobile.py` ve `router_gateway.py` içindedir.
|
||||||
|
Mevcut member guard korunur. Üretim kurulumu ve TURN koşulları eş gateway
|
||||||
|
deposunun `docs/aiturk-mobile-operations.md` dosyasında açıklanır.
|
||||||
|
|
||||||
|
## Doğrulama ve sınırlar
|
||||||
|
|
||||||
|
0.1.4-dev (Android versionCode 5), 12 Eylül 2026: 41 mobil test geçti;
|
||||||
|
tarayıcı uygulama testi, yerel sertifika/DTLS testi ve Electron IPC/Windows kasa
|
||||||
|
testi geçti. Telefon kimliği QR resminin onaysız içe alınması ve değiştirilmiş
|
||||||
|
cihaz kimliklerinin reddi test edildi. `assembleDebug lintDebug` başarılı,
|
||||||
|
uygulama lint sonucu 0 hata / 17 uyarı. Debug APK imzası doğrulandı ve kimlik,
|
||||||
|
taşıma, uygulama, davet, gizlilik ve ana sayfa dosyaları kaynakla karşılaştırıldı.
|
||||||
|
APK SHA-256: `94835E223B71BE36AFB26686D3EBC79D8976514FFDB4E7CDFC189ACE7E2F12A5`.
|
||||||
|
Bu APK geliştirici imzalıdır; üretim/gerçek Android kabulü değildir.
|
||||||
|
|
||||||
|
12 Eylül 2026 yerel doğrulaması: `npm test` 37/37 geçti. Ses başlangıcı
|
||||||
|
beklerken iptal ve kapatılmış oturumu yeniden açma regresyonları önce başarısız
|
||||||
|
testlerle gösterildi, sonra düzeltildi. `npm run android:sync` ve
|
||||||
|
`gradlew.bat assembleDebug lintDebug` başarılı; uygulama lint sonucu 0 hata,
|
||||||
|
17 uyarı. `scripts/browser-smoke.mjs` gerçek DTLS veri kanalında 240.000
|
||||||
|
karakter aktarımını ve sentetik Google kullanım sınırını doğruladı; Google'a
|
||||||
|
gerçek çağrı yapmadı. APK içindeki `voice.mjs` kaynakla birebir karşılaştırıldı.
|
||||||
|
Debug imzası `apksigner verify` ile doğrulandı; bu üretim imzası değildir.
|
||||||
|
APK SHA-256: `F5DB098DF0F1C59B92F274969FCAD926BCB2DD25AF823533A983085A18BFB966`.
|
||||||
|
Bu kontrolde `adb devices -l` bağlı cihaz göstermedi; fiziksel telefon kabulü
|
||||||
|
ve canlı yayın hâlâ tamamlanmadı.
|
||||||
|
|
||||||
|
`npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları.
|
||||||
|
`scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı,
|
||||||
|
240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi.
|
||||||
|
`scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu;
|
||||||
|
yalnız sentetik bir test anahtarı kullanır.
|
||||||
|
|
||||||
|
`src/peer-identity.mjs` cihaz kimliğine göre kalıcı standart RTCCertificate
|
||||||
|
kaydını, atomik ilk oluşturmayı, kayıtlı parmak iziyle yüklemeyi ve SDP kimlik
|
||||||
|
kontrolünü uygular. Anahtar eksik veya geçersizse yükleme yeni sertifika üretmez.
|
||||||
|
Bilgisayar QR daveti sürüm 2 ile sertifika parmak izini taşır; telefon bunu
|
||||||
|
eşleştirme kaydına alır ve SDP uygulanmadan önce kontrol eder. Bilgisayar
|
||||||
|
bağlantıda kayıtlı sertifikasını kullanır. Eski sürüm 1 QR ve kimliği olmayan
|
||||||
|
elle eşleştirmeler veri bağlantısı açamaz; yeni QR ile yeniden eşleştirme gerekir.
|
||||||
|
Telefon da eşleştirmede kendi sertifikasını oluşturur ve bağlantıda aynı
|
||||||
|
sertifikayı yükler. Kullanıcı telefondaki kimlik belgesini bilgisayardaki
|
||||||
|
ilgili alana aktarır; `phone.approve` belgeyi eşleştirme kimliği ve sunucunun
|
||||||
|
onayladığı cihaz kimliğiyle karşılaştırıp Windows kasasına kaydeder. IDE hem
|
||||||
|
gelen bağlantı listesinde hem `channel.answer` yolunda kayıtlı telefon
|
||||||
|
parmak izini kontrol eder; renderer SDP'yi uygulamadan önce tekrar doğrular.
|
||||||
|
İlk kayıt için yerel açık onay gerekir; mevcut telefon kimliği sessizce
|
||||||
|
değiştirilemez. Telefon kimliğini QR olarak da gösterir. Kullanıcı ekran
|
||||||
|
görüntüsünü kendi bilgisayarına aktararak “Telefon kimliği QR resmi” alanından
|
||||||
|
okutabilir; eşleştirme kimliği ve belge doldurulur, fakat otomatik onay verilmez.
|
||||||
|
Kısa kod ve ayrı onay düğmesi gereklidir; elle belge aktarma yolu da korunur.
|
||||||
|
|
||||||
|
`node scripts/certificate-smoke.mjs`: 12 Eylül 2026'da izole Edge bağlamında
|
||||||
|
yerel WebRTC sertifikasının IndexedDB'ye yazılıp sayfa yenilendikten sonra
|
||||||
|
aynı parmak iziyle geri alınması, geri alınan sertifikayla gerçek DTLS veri
|
||||||
|
aktarımı ve yanlış SDP parmak izinde DTLS'nin `failed` durumuna geçmesi
|
||||||
|
doğrulandı. Test artık gerçek `peer-identity.mjs` modülünü kullanıyor; eşzamanlı
|
||||||
|
dört oluşturma çağrısının tek kimlikte birleşmesi, kayıp kaydın ve yanlış
|
||||||
|
parmak izinin reddi de geçti. Tarayıcı uygulama testi ayrıca QR ile eşlenen
|
||||||
|
bilgisayar yerine farklı sertifikalı SDP geldiğinde görünür kimlik hatası
|
||||||
|
verildiğini ve hiçbir IDE isteği gönderilmediğini doğruladı. Electron IPC testi
|
||||||
|
Windows kasasındaki parmak iziyle sertifikanın pencere yenilemesinden sonra
|
||||||
|
yüklenmesini ve sessiz kimlik değişiminin reddini doğruladı. Bu testlerde portal
|
||||||
|
ve Google gerçek servisleri kullanılmadı; canlı karşılıklı kimlik kabulü değildir.
|
||||||
|
|
||||||
|
Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/run_tests.sh tests/tui_gateway/test_mobile_runtime_integration.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Bu test gerçek IDE HTTP, WebSocket, konuşma veritabanı, prompt.submit ve
|
||||||
|
message.complete yolunu çalıştırır. Ücretli çıkarım servisi yerine yerel
|
||||||
|
deterministik sağlayıcı kullanılır. Aynı görev ikinci defa gönderildiğinde IDE
|
||||||
|
geçmişinde yalnız bir kullanıcı görevi oluşur. Gerçek Google/iş modeli kalite
|
||||||
|
ve maliyet testi olarak sayılmaz.
|
||||||
|
|
||||||
|
12 Eylül 2026, 0.1.4 kaynak doğrulaması: zorunlu `scripts/run_tests.sh`
|
||||||
|
sarmalayıcısıyla `test_compute_host.py`, `test_compute_host_phase1.py` ve
|
||||||
|
`test_mobile_runtime_integration.py` birlikte çalıştırıldı. 3 dosyada 16 test
|
||||||
|
geçti, 0 başarısız; süre 32,9 saniye. Windows venv yönlendirici sürecinin
|
||||||
|
bildirdiği PID, gerçek süreç soy ağacı üzerinden doğrulanır. Bu koşu yalnız
|
||||||
|
yerel sentetik sağlayıcı kullanır, telefon donanımı veya Google kabulü değildir.
|
||||||
|
|
||||||
|
Henüz tamamlanmayanlar:
|
||||||
|
|
||||||
|
- Karşılıklı cihaz kimliğinin gerçek Android ve paketlenmiş IDE arasında,
|
||||||
|
yeniden başlatma/iptal/anahtar kaybı senaryolarıyla uçtan uca kabulü.
|
||||||
|
İki yönün kodu var; tarayıcı ve Electron testleri gerçek cihaz kabulünün
|
||||||
|
yerine geçmez. Telefon QR çıktısının pikselden geri okunması ve Electron'da
|
||||||
|
QR resminin açık onay vermeden alanlara aktarılması test edildi; gerçek
|
||||||
|
telefon ekran görüntüsü aktarımı henüz denenmedi.
|
||||||
|
- Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi.
|
||||||
|
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
|
||||||
|
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
|
||||||
|
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
|
||||||
|
- Giriş ve QR artık kodlandı: 9 HTTP/DB testi, 27 mobil testi, gerçek QR pikseli
|
||||||
|
okuyan (hesap/claim yanıtları sahte) tarayıcı akışı geçti. Fiziksel Android
|
||||||
|
kamera/giriş kabulü ve farklı ağ bağlantısı henüz yapılmadı.
|
||||||
|
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
|
||||||
|
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
|
||||||
|
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
|
||||||
|
- Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev
|
||||||
|
sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç
|
||||||
|
bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz.
|
||||||
|
- Maliyet tahmini/ayarlanabilir oturum sınırı uygulandı: 35 Node testi ve
|
||||||
|
derlenmiş uygulamanın gerçek WebRTC, sentetik IDE/Google yanıtlı sınır akışı
|
||||||
|
geçti. Gerçek Google faturasına karşı karşılaştırma henüz yapılmadı.
|
||||||
|
- Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış
|
||||||
|
kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin
|
||||||
|
sinyalleşme hizmetine güven sınırı gizlenmez.
|
||||||
|
- Üretim imzası, kapalı beta kabulü ve turkservis.online indirme yayını.
|
||||||
|
|
||||||
|
Capacitor CLI'nin kullanılmayan iOS araç zincirinde `xcode → uuid` üzerinden
|
||||||
|
3 moderate geliştirme bağımlılığı uyarısı mevcut; Android runtime audit sonucu
|
||||||
|
ayrıca kontrol edilir. Bunları gizlemek için `npm audit fix --force` uygulanmadı.
|
||||||
|
|
||||||
|
## Resmî dayanaklar
|
||||||
|
|
||||||
|
- https://capacitorjs.com/docs/getting-started/environment-setup
|
||||||
|
- https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens
|
||||||
|
- https://ai.google.dev/api/live
|
||||||
|
- https://ai.google.dev/gemini-api/docs/pricing
|
||||||
|
- https://github.com/coturn/coturn/blob/master/README.turnserver
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "online.turkservis.aiturk.mobile"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "online.turkservis.aiturk.mobile"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 5
|
||||||
|
versionName "0.1.4-dev"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:usesCleartextTraffic="false"
|
||||||
|
android:icon="@drawable/aiturk_icon"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@drawable/aiturk_icon"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package online.turkservis.aiturk.mobile;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override public void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(MobileSecretsPlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package online.turkservis.aiturk.mobile;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.security.keystore.KeyGenParameterSpec;
|
||||||
|
import android.security.keystore.KeyProperties;
|
||||||
|
import android.util.Base64;
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.net.ssl.HttpsURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/** Standard Android Keystore AEAD storage. Key never leaves AndroidKeyStore. */
|
||||||
|
@CapacitorPlugin(name = "MobileSecrets")
|
||||||
|
public class MobileSecretsPlugin extends Plugin {
|
||||||
|
private static final String ALIAS = "aiturk.mobile.account.v1";
|
||||||
|
private final AtomicInteger requests = new AtomicInteger();
|
||||||
|
@PluginMethod public void openLogin(PluginCall call) {
|
||||||
|
String url = call.getString("url", "");
|
||||||
|
if (!url.matches("https://turkservis\\.online/account/aiturk/mobile-login/[0-9a-f-]{36}")) {
|
||||||
|
call.reject("Geçersiz giriş adresi."); return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getActivity().startActivity(new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)));
|
||||||
|
call.resolve();
|
||||||
|
} catch (Exception error) { call.reject("Tarayıcı açılamadı."); }
|
||||||
|
}
|
||||||
|
@PluginMethod public void request(PluginCall call) {
|
||||||
|
String path = call.getString("path", "");
|
||||||
|
String method = call.getString("method", "GET");
|
||||||
|
String body = call.getString("body", "");
|
||||||
|
if (!path.matches("/v1/aiturk/[A-Za-z0-9/_.-]+") || path.contains("..") ||
|
||||||
|
!method.matches("GET|POST|DELETE") || body.length() > 65536) {
|
||||||
|
call.reject("Geçersiz hesap isteği."); return;
|
||||||
|
}
|
||||||
|
if (requests.incrementAndGet() > 8) { requests.decrementAndGet(); call.reject("Bekleyen hesap istekleri var."); return; }
|
||||||
|
new Thread(() -> {
|
||||||
|
HttpsURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
connection = (HttpsURLConnection) new URL("https://turkservis.online" + path).openConnection();
|
||||||
|
connection.setInstanceFollowRedirects(false);
|
||||||
|
connection.setConnectTimeout(20000); connection.setReadTimeout(20000);
|
||||||
|
connection.setRequestMethod(method);
|
||||||
|
JSObject headers = call.getObject("headers", new JSObject());
|
||||||
|
for (String name : new String[] {"Authorization", "X-Aiturk-Device-ID", "X-Aiturk-Device-Token"}) {
|
||||||
|
String value = headers.getString(name, "");
|
||||||
|
if (value.length() > 1024 || value.contains("\r") || value.contains("\n")) throw new IllegalArgumentException();
|
||||||
|
if (!value.isEmpty()) connection.setRequestProperty(name, value);
|
||||||
|
}
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
|
if (!body.isEmpty()) {
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
try (java.io.OutputStream out = connection.getOutputStream()) { out.write(body.getBytes(StandardCharsets.UTF_8)); }
|
||||||
|
}
|
||||||
|
int status = connection.getResponseCode();
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
InputStream input = status < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||||
|
if (input != null) try (InputStream stream = input) {
|
||||||
|
byte[] buffer = new byte[4096]; int count;
|
||||||
|
while ((count = stream.read(buffer)) != -1) {
|
||||||
|
if (bytes.size() + count > 262144) throw new IllegalStateException();
|
||||||
|
bytes.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSObject result = new JSObject(); result.put("status", status);
|
||||||
|
result.put("body", bytes.toString(StandardCharsets.UTF_8.name())); call.resolve(result);
|
||||||
|
} catch (Exception error) { call.reject("Hesap bağlantısı kurulamadı."); }
|
||||||
|
finally { if (connection != null) connection.disconnect(); requests.decrementAndGet(); }
|
||||||
|
}, "aiturk-account").start();
|
||||||
|
}
|
||||||
|
private SecretKey key() throws Exception {
|
||||||
|
KeyStore store = KeyStore.getInstance("AndroidKeyStore"); store.load(null);
|
||||||
|
if (!store.containsAlias(ALIAS)) {
|
||||||
|
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||||
|
generator.init(new KeyGenParameterSpec.Builder(ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build());
|
||||||
|
generator.generateKey();
|
||||||
|
}
|
||||||
|
return (SecretKey) store.getKey(ALIAS, null);
|
||||||
|
}
|
||||||
|
@PluginMethod public void save(PluginCall call) {
|
||||||
|
String value = call.getString("value");
|
||||||
|
if (value == null || value.length() > 65536) { call.reject("Geçersiz hesap kaydı."); return; }
|
||||||
|
try {
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||||
|
String encrypted = Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP) + ":" +
|
||||||
|
Base64.encodeToString(cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)), Base64.NO_WRAP);
|
||||||
|
boolean saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE)
|
||||||
|
.edit().putString("account", encrypted).commit();
|
||||||
|
if (!saved) throw new IllegalStateException();
|
||||||
|
call.resolve();
|
||||||
|
} catch (Exception error) { call.reject("Android güvenli kasasına kaydedilemedi."); }
|
||||||
|
}
|
||||||
|
@PluginMethod public void load(PluginCall call) {
|
||||||
|
try {
|
||||||
|
String saved = getContext().getSharedPreferences("aiturk-mobile", Context.MODE_PRIVATE).getString("account", null);
|
||||||
|
JSObject result = new JSObject();
|
||||||
|
if (saved != null) {
|
||||||
|
String[] parts = saved.split(":", 2);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)));
|
||||||
|
result.put("value", new String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
call.resolve(result);
|
||||||
|
} catch (Exception error) { call.reject("Android güvenli kasası açılamadı."); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |