Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0a45a84f | ||
|
|
67a99be224 | ||
|
|
9240576937 | ||
|
|
233885d3a7 | ||
|
|
9a1d831d4b | ||
|
|
1fe367177c | ||
|
|
40c3c74282 | ||
|
|
ee73fa8cce | ||
|
|
f960716483 | ||
|
|
5f75dcc99a | ||
|
|
ac6687181e | ||
|
|
3f12c627ab | ||
|
|
4115f1b4c0 |
Binary file not shown.
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -33,6 +33,7 @@ import {
|
||||
} from 'electron'
|
||||
|
||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||
import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime'
|
||||
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
|
||||
import { appIconCandidates, resolveAppIcon } from './app-icon'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||
@@ -5221,6 +5222,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.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||
updateBootProgress({
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
|
||||
import { drawQr, InviteScanner } from './mobile/qr.mjs'
|
||||
import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.mjs'
|
||||
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
|
||||
const status = text => { $('status').textContent = text }
|
||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||
const identityScanner = new InviteScanner(document.createElement('video'), async raw => {
|
||||
if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.')
|
||||
const value = JSON.parse(raw)
|
||||
const pairingId = $('pairing').value.trim() || value.pairingId
|
||||
parsePhoneIdentity(raw, pairingId)
|
||||
if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.')
|
||||
$('pairing').value = pairingId; $('phone-identity').value = raw
|
||||
status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.')
|
||||
})
|
||||
$('phone-identity-file').onchange = async event => {
|
||||
try { await identityScanner.image(event.target.files[0]) }
|
||||
catch (error) { status(error.message) }
|
||||
finally { event.target.value = '' }
|
||||
}
|
||||
action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` })
|
||||
action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') })
|
||||
action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') })
|
||||
async function desktopIdentity(create = false) {
|
||||
const setup = await host.call('setup')
|
||||
if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.')
|
||||
if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint)
|
||||
if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.')
|
||||
const identity = await createDeviceIdentity(setup.deviceId)
|
||||
await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint })
|
||||
return identity
|
||||
}
|
||||
action('invite', async () => {
|
||||
const identity = await desktopIdentity(true), invite = await host.call('invite.create')
|
||||
drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint }))
|
||||
$('invite-qr').hidden = false
|
||||
$('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}`
|
||||
})
|
||||
action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) })
|
||||
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
|
||||
action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` })
|
||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase() }); $('phone-code').value = ''; status('Telefon bu bilgisayarda onaylandı.') })
|
||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') })
|
||||
action('refresh', async () => {
|
||||
const data = await host.call('devices'); $('devices').replaceChildren()
|
||||
for (const device of data.devices) {
|
||||
@@ -21,8 +53,10 @@ action('refresh', async () => {
|
||||
function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) }
|
||||
host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) })
|
||||
async function connect(offer) {
|
||||
assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint)
|
||||
const ice = await host.call('ice')
|
||||
const pc = new RTCPeerConnection(rtcConfiguration(ice.iceServers)), entry = { pc }; peers.set(offer.id, entry)
|
||||
const identity = await desktopIdentity()
|
||||
const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry)
|
||||
pc.ondatachannel = event => {
|
||||
const channel = event.channel; entry.channel = channel
|
||||
if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return }
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
|
||||
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
|
||||
<p id="code"></p><hr>
|
||||
<button id="invite">Telefon için QR oluştur</button><canvas id="invite-qr" hidden aria-label="Süreli telefon eşleştirme QR kodu"></canvas>
|
||||
<p id="invite-status"></p><button id="invite-check">QR ile gelen telefonu getir</button>
|
||||
<h2>Telefona izin ver</h2><p>Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.</p>
|
||||
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
|
||||
<label>Telefondan alınan kimlik belgesi<textarea id="phone-identity" maxlength="700"></textarea></label>
|
||||
<label>Telefon kimliği QR resmi<input id="phone-identity-file" type="file" accept="image/*"></label>
|
||||
<p>Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.</p>
|
||||
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
|
||||
<h2>Cihazlar</h2><button id="refresh">Cihaz listesini yenile</button><div id="devices"></div>
|
||||
<p>Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.</p>
|
||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
</main><script src="mobile/vendor/jsQR.js"></script><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
|
||||
import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs'
|
||||
import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs'
|
||||
import { AccountClient } from '../../mobile/src/transport.mjs'
|
||||
import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs'
|
||||
|
||||
export function createMobileWindow({ directory, assets, resolveBackend, show = true }) {
|
||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||
@@ -23,7 +24,16 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.')
|
||||
if (action === 'setup') {
|
||||
const secrets = await vault.load()
|
||||
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey) }
|
||||
return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey),
|
||||
deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint }
|
||||
}
|
||||
if (action === 'identity.save') {
|
||||
const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint)
|
||||
if (!previous.deviceId || previous.deviceId !== params.deviceId ||
|
||||
(previous.localFingerprint && previous.localFingerprint !== fingerprint))
|
||||
throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.')
|
||||
await vault.save({ ...previous, localFingerprint: fingerprint })
|
||||
return { saved: true }
|
||||
}
|
||||
if (action === 'account.save') {
|
||||
if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.')
|
||||
@@ -44,6 +54,7 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
}
|
||||
if (action === 'account.login.check') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Mevcut hesap veya bağlantı sessizce değiştirilemez.')
|
||||
if (!previous.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||
const result = await new AccountClient({ accountToken: previous.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||
if (result.state !== 'approved') return { approved: false }
|
||||
@@ -67,9 +78,19 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
}
|
||||
if (action === 'phone.approve') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.')
|
||||
return call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
||||
const identity = parsePhoneIdentity(params.identity, params.pairingId)
|
||||
const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId]
|
||||
if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.')
|
||||
const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
||||
if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.')
|
||||
const current = await vault.load()
|
||||
if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.')
|
||||
await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } })
|
||||
return result
|
||||
}
|
||||
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
|
||||
if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {})
|
||||
if (action === 'invite.check') return call('/v1/aiturk/mobile/invites')
|
||||
if (action === 'device.revoke') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.deviceId) || !Number.isInteger(params.version)) throw new Error('Geçersiz cihaz.')
|
||||
const result = await call(`/v1/aiturk/devices/${params.deviceId}/revoke`, { expected_version: params.version })
|
||||
@@ -79,12 +100,20 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
if (result.self_revoked) await vault.save({})
|
||||
return result
|
||||
}
|
||||
if (action === 'inbox') return call('/v1/aiturk/mobile/channels')
|
||||
if (action === 'inbox') {
|
||||
const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load()
|
||||
return { ...data, channels: data.channels.map(offer => {
|
||||
const fingerprint = secrets.phoneFingerprints?.[offer.phone_id]
|
||||
assertRemoteIdentity(offer.sdp, fingerprint)
|
||||
return { ...offer, trustedPhoneFingerprint: fingerprint }
|
||||
}) }
|
||||
}
|
||||
if (action === 'ice') return call('/v1/aiturk/mobile/ice')
|
||||
if (action === 'channel.answer') {
|
||||
const inbox = await call('/v1/aiturk/mobile/channels')
|
||||
const offer = inbox.channels.find(row => row.id === params.channelId)
|
||||
if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.')
|
||||
assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id])
|
||||
const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {})
|
||||
const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }),
|
||||
{ deviceId: offer.phone_id, journal, authorize })
|
||||
|
||||
@@ -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",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.10",
|
||||
"version": "1.0.0-beta.13",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
@@ -290,7 +290,17 @@
|
||||
"nsis",
|
||||
"msi"
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
"signAndEditExecutable": false,
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "assets/windows/vc-runtime/x64",
|
||||
"to": "vc-runtime/x64",
|
||||
"filter": [
|
||||
"*.dll",
|
||||
"manifest.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||
// separately via stage-native-deps).
|
||||
import { build } from 'esbuild'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdirSync, cpSync } from 'node:fs'
|
||||
import { mkdirSync, cpSync, existsSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
@@ -67,6 +68,21 @@ for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cj
|
||||
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||
for (const name of ['transport.mjs', 'style.css']) {
|
||||
for (const name of ['transport.mjs', 'peer-identity.mjs', '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'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js'))
|
||||
cpSync(resolve(mobilePackageDir('jsqr'), 'LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt'))
|
||||
|
||||
+85
-7
@@ -1,4 +1,4 @@
|
||||
# AITURK Mobil — Android geliştirme sürümü 0.1.1
|
||||
# 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ı.
|
||||
@@ -25,6 +25,14 @@ turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi.
|
||||
ü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
|
||||
|
||||
@@ -55,8 +63,10 @@ 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ıp eşleştirme kodu oluşturun.
|
||||
4. Telefondaki kimlik/kodu bilgisayardaki mobil pencerede açıkça 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.
|
||||
@@ -68,12 +78,65 @@ 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
|
||||
@@ -86,22 +149,37 @@ deterministik sağlayıcı kullanılır. Aynı görev ikinci defa gönderildiği
|
||||
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ış.
|
||||
- QR tarama. Tarayıcı kısa kod girişi kodlandı ve HTTP/DB ile sahte yanıtlı
|
||||
tarayıcı arayüz testleri geçti; gerçek Android giriş kabulü henüz yapılmadı.
|
||||
- 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.
|
||||
- Doğrulanmış maliyet tahmini/ayarlanabilir bütçe. Yerel görüşme süresi ve
|
||||
10 dakikalık oturum sınırı Google kotası veya fatura üst sınırı değildir.
|
||||
- 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.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "online.turkservis.aiturk.mobile"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "0.1.1-dev"
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,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
|
||||
|
||||
Generated
+17
-3
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "aiturk-mobile",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aiturk-mobile",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.5.1",
|
||||
"@capacitor/core": "8.5.1"
|
||||
"@capacitor/core": "8.5.1",
|
||||
"jsqr": "1.4.0",
|
||||
"qrcode-generator": "2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.5.1"
|
||||
@@ -637,6 +639,12 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -827,6 +835,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aiturk-mobile",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,6 @@
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"android:sync": "npm run build && cap sync android"
|
||||
},
|
||||
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1" },
|
||||
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1", "jsqr": "1.4.0", "qrcode-generator": "2.0.4" },
|
||||
"devDependencies": { "@capacitor/cli": "8.5.1" }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 })
|
||||
page.setDefaultTimeout(15000)
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message))
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||
await page.getByRole('heading', { name: 'Fikrini konuş. Birlikte geliştir.' }).waitFor()
|
||||
@@ -37,6 +38,111 @@ try {
|
||||
await page.getByText('Yalnız kendi hesabınızda bu kodu onaylayın: ABCDEFGH').waitFor()
|
||||
await page.getByRole('button', { name: 'Girişi kontrol et' }).click()
|
||||
await page.getByText('Hesaba giriş yapıldı.', { exact: true }).waitFor()
|
||||
let claimed = false
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/invites/**/claim', async route => {
|
||||
claimed = true
|
||||
const body = route.request().postDataJSON(); assert.equal(body.secret, 'a'.repeat(43))
|
||||
await route.fulfill({ status: 201, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: JSON.stringify({ device_id: '33333333-3333-4333-8333-333333333333', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
|
||||
})
|
||||
const qrImage = await page.evaluate(async () => {
|
||||
const { drawQr } = await import('./qr.mjs'), canvas = document.createElement('canvas')
|
||||
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
const desktopFingerprint = window.fixtureDesktopCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value
|
||||
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 2, desktopFingerprint, inviteId: '11111111-1111-4111-8111-111111111111',
|
||||
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(Date.now() + 600000).toISOString() }))
|
||||
return canvas.toDataURL('image/png').split(',')[1]
|
||||
})
|
||||
await page.locator('#qr-file').setInputFiles({ name: 'invite.png', mimeType: 'image/png', buffer: Buffer.from(qrImage, 'base64') })
|
||||
await page.getByText('Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: QRSTUVWX').waitFor()
|
||||
assert.equal(claimed, true)
|
||||
const phoneQr = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('phone-identity-qr')
|
||||
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
|
||||
return { decoded: jsQR(pixels.data, pixels.width, pixels.height)?.data,
|
||||
expected: document.getElementById('phone-identity').value, visible: !canvas.hidden }
|
||||
})
|
||||
assert.equal(phoneQr.visible, true)
|
||||
assert.equal(phoneQr.decoded, phoneQr.expected)
|
||||
// Run the actual app -> AccountClient -> WebRTC -> VoiceSession wiring.
|
||||
// Portal, IDE responses and Google frames are synthetic; no paid/API traffic.
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/devices', route => route.fulfill({
|
||||
json: { devices: [{ id: '22222222-2222-4222-8222-222222222222', name: 'Test bilgisayarı', platform: 'windows', self: false }] } }))
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/ice', route => route.fulfill({ json: { iceServers: [] } }))
|
||||
await page.route('https://turkservis.online/v1/aiturk/mobile/channels**', async route => {
|
||||
if (route.request().method() === 'POST' && !route.request().url().endsWith('/heartbeat')) {
|
||||
const { sdp } = route.request().postDataJSON()
|
||||
await page.evaluate(async sdp => {
|
||||
const { completeIce, sendFrame } = await import('./transport.mjs')
|
||||
const { assertRemoteIdentity } = await import('./peer-identity.mjs')
|
||||
const trustedPhone = JSON.parse(document.getElementById('phone-identity').value)
|
||||
assertRemoteIdentity(sdp, trustedPhone.fingerprint)
|
||||
const pc = new RTCPeerConnection({ certificates: [window.fixtureDesktopCertificate] }); window.fixtureDesktop = pc; window.fixtureActions = []
|
||||
pc.ondatachannel = event => { event.channel.onmessage = incoming => {
|
||||
const request = JSON.parse(incoming.data); window.fixtureActions.push(request.action)
|
||||
const results = {
|
||||
'sessions.list': { sessions: [{ id: 'fixture-session', title: 'Test işi', model: 'fixture-model' }] },
|
||||
'session.open': { model: 'fixture-model', messages: [], approval: null, running: false },
|
||||
'voice.token': { token: 'fixture-only', model: 'gemini-3.1-flash-live-preview' },
|
||||
}
|
||||
sendFrame(event.channel, { id: request.id, result: results[request.action] || {} })
|
||||
} }
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp })
|
||||
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
|
||||
window.fixtureAnswer = pc.localDescription.sdp
|
||||
}, sdp)
|
||||
await route.fulfill({ json: {} })
|
||||
} else await route.fulfill({ json: { sdp: await page.evaluate(() => window.fixtureAnswer) } })
|
||||
})
|
||||
await page.evaluate(() => {
|
||||
window.fixtureVoiceSockets = []
|
||||
window.WebSocket = class {
|
||||
constructor(url) {
|
||||
if (!url.startsWith('wss://generativelanguage.googleapis.com/')) throw new Error('Unexpected test socket')
|
||||
this.readyState = 1; this.bufferedAmount = 0; window.fixtureVoiceSockets.push(this)
|
||||
queueMicrotask(() => this.onopen?.())
|
||||
}
|
||||
send(raw) {
|
||||
if (JSON.parse(raw).setup) queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ setupComplete: {} }) }))
|
||||
}
|
||||
close() { this.readyState = 3; this.onclose?.() }
|
||||
}
|
||||
})
|
||||
await page.getByRole('button', { name: 'Onaydan sonra bilgisayarları bul' }).click()
|
||||
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||
await page.getByText('Bilgisayar bağlı. IDE konuşmasını seçin.', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: 'Konuşmaları yenile' }).click()
|
||||
await page.getByRole('button', { name: 'Bu konuşmayı seç', exact: true }).click()
|
||||
await page.getByText('IDE konuşması açıldı.', { exact: true }).waitFor()
|
||||
await page.locator('#consent').check()
|
||||
await page.locator('#voice-budget').fill('0.01')
|
||||
await page.locator('#voice-minutes').fill('3')
|
||||
await page.getByRole('button', { name: 'Asistanla yazış', exact: true }).click()
|
||||
await page.getByText('Google asistanı bağlı.', { exact: true }).waitFor()
|
||||
assert.match(await page.locator('#usage').textContent(), /henüz bilinmiyor/)
|
||||
await page.evaluate(() => window.fixtureVoiceSockets[0].onmessage({ data: JSON.stringify({ usageMetadata: {
|
||||
promptTokenCount: 1000, responseTokenCount: 1000, totalTokenCount: 2000,
|
||||
promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||
responseTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
|
||||
} }) }))
|
||||
await page.locator('#voice-status').filter({ hasText: 'maliyet sınırına ulaşıldı' }).waitFor()
|
||||
assert.match(await page.locator('#usage').textContent(), /0\.0150–0\.0150 USD/)
|
||||
assert.equal(await page.evaluate(() => window.fixtureVoiceSockets[0].readyState), 3)
|
||||
assert.deepEqual(await page.evaluate(() => window.fixtureActions), ['sessions.list', 'session.open', 'voice.token'])
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('aiturk.voice-limits'))), { minutes: 3, usd: 0.01 })
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
await mkdir('test-results', { recursive: true })
|
||||
await page.screenshot({ path: 'test-results/android-voice-limit.png', fullPage: true })
|
||||
await page.evaluate(() => window.fixtureDesktop.close())
|
||||
// A signaling service returning another machine's valid SDP must not replace
|
||||
// the identity captured from the user's QR or receive any IDE requests.
|
||||
await page.evaluate(async () => {
|
||||
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
})
|
||||
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
|
||||
await page.locator('#status').filter({ hasText: 'Cihaz kimliği doğrulanamadı' }).waitFor()
|
||||
assert.deepEqual(await page.evaluate(() => window.fixtureActions), [])
|
||||
await page.evaluate(() => window.fixtureDesktop.close())
|
||||
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
|
||||
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
|
||||
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
|
||||
@@ -67,5 +173,6 @@ try {
|
||||
})
|
||||
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
|
||||
assert.deepEqual(errors, [])
|
||||
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realWebRTC: result, errors }))
|
||||
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed',
|
||||
appWebRTCWithSyntheticGoogleUsageLimit: 'passed', qrPinnedDesktopReplacement: 'rejected', realWebRTC: result, errors }))
|
||||
} finally { await browser.close(); server.close() }
|
||||
|
||||
@@ -5,4 +5,8 @@ await mkdir(new URL('../dist', import.meta.url), { recursive: true })
|
||||
await cp(new URL('../src', import.meta.url), new URL('../dist', import.meta.url), { recursive: true })
|
||||
const core = dirname(createRequire(import.meta.url).resolve('@capacitor/core/package.json'))
|
||||
await cp(join(core, 'dist', 'capacitor.js'), new URL('../dist/capacitor.js', import.meta.url))
|
||||
await mkdir(new URL('../dist/vendor', import.meta.url), { recursive: true })
|
||||
await cp(new URL('../node_modules/qrcode-generator/dist/qrcode.mjs', import.meta.url), new URL('../dist/vendor/qrcode.mjs', import.meta.url))
|
||||
await cp(new URL('../node_modules/jsqr/dist/jsQR.js', import.meta.url), new URL('../dist/vendor/jsQR.js', import.meta.url))
|
||||
await cp(new URL('../node_modules/jsqr/LICENSE', import.meta.url), new URL('../dist/vendor/jsQR-LICENSE.txt', import.meta.url))
|
||||
console.log('AITURK Android web assets built.')
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Real identity-module + native DTLS test; full app pairing is tested separately.
|
||||
// Synthetic local peers, isolated browser context, no account or Google requests.
|
||||
import { chromium } from 'playwright'
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.url === '/peer-identity.mjs') {
|
||||
try {
|
||||
const source = await readFile(new URL('../src/peer-identity.mjs', import.meta.url))
|
||||
response.writeHead(200, { 'Content-Type': 'text/javascript' }); response.end(source)
|
||||
} catch { response.writeHead(500); response.end() }
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
response.end('<!doctype html><title>Local certificate test</title>')
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage()
|
||||
page.setDefaultTimeout(20000)
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`)
|
||||
const original = await page.evaluate(async () => {
|
||||
const { createDeviceIdentity } = await import('/peer-identity.mjs')
|
||||
const results = await Promise.all(Array.from({ length: 4 }, () => createDeviceIdentity('11111111-1111-4111-8111-111111111111')))
|
||||
if (new Set(results.map(value => value.fingerprint)).size !== 1) throw new Error('Concurrent identity creation diverged')
|
||||
const { certificate, fingerprint } = results[0]
|
||||
return { fingerprints: certificate.getFingerprints(), fingerprint, expires: certificate.expires }
|
||||
})
|
||||
await page.reload()
|
||||
const result = await page.evaluate(async expected => {
|
||||
const { loadDeviceIdentity, assertRemoteIdentity } = await import('/peer-identity.mjs')
|
||||
const { certificate } = await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', expected)
|
||||
let missingRejected = false, wrongPinRejected = false
|
||||
try { await loadDeviceIdentity('22222222-2222-4222-8222-222222222222', expected) } catch { missingRejected = true }
|
||||
try { await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', Array(32).fill('00').join(':')) } catch { wrongPinRejected = true }
|
||||
const peers = []
|
||||
const complete = async pc => {
|
||||
if (pc.iceGatheringState === 'complete') return
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error('ICE timeout')) }, 10000)
|
||||
const check = () => { if (pc.iceGatheringState === 'complete') { cleanup(); resolve() } }
|
||||
const cleanup = () => { clearTimeout(timeout); pc.removeEventListener('icegatheringstatechange', check) }
|
||||
pc.addEventListener('icegatheringstatechange', check); check()
|
||||
})
|
||||
}
|
||||
try {
|
||||
const phone = new RTCPeerConnection({ iceServers: [] })
|
||||
const desktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||
peers.push(phone, desktop)
|
||||
const channel = phone.createDataChannel('certificate-probe')
|
||||
desktop.ondatachannel = event => { event.channel.onmessage = message => event.channel.send(message.data) }
|
||||
await phone.setLocalDescription(await phone.createOffer()); await complete(phone)
|
||||
await desktop.setRemoteDescription(phone.localDescription)
|
||||
await desktop.setLocalDescription(await desktop.createAnswer()); await complete(desktop)
|
||||
const advertised = desktop.localDescription.sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
|
||||
assertRemoteIdentity(desktop.localDescription.sdp, expected)
|
||||
await phone.setRemoteDescription(desktop.localDescription)
|
||||
const echo = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('DTLS echo timeout')), 15000)
|
||||
channel.onmessage = event => { clearTimeout(timeout); resolve(event.data) }
|
||||
channel.onopen = () => channel.send('Türkçe kimlik doğrulama 🟢')
|
||||
if (channel.readyState === 'open') channel.onopen()
|
||||
})
|
||||
const goodDtls = phone.sctp.transport.state
|
||||
const wrongCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
const wrongFingerprint = wrongCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value.toUpperCase()
|
||||
let untrustedPeerRejected = false
|
||||
try { assertRemoteIdentity(desktop.localDescription.sdp, wrongFingerprint) } catch { untrustedPeerRejected = true }
|
||||
const badPhone = new RTCPeerConnection({ iceServers: [] })
|
||||
const badDesktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
|
||||
peers.push(badPhone, badDesktop)
|
||||
const badChannel = badPhone.createDataChannel('certificate-mismatch')
|
||||
let mismatchOpened = false
|
||||
badChannel.onopen = () => { mismatchOpened = true }
|
||||
await badPhone.setLocalDescription(await badPhone.createOffer()); await complete(badPhone)
|
||||
await badDesktop.setRemoteDescription(badPhone.localDescription)
|
||||
await badDesktop.setLocalDescription(await badDesktop.createAnswer()); await complete(badDesktop)
|
||||
await badPhone.setRemoteDescription({ type: 'answer', sdp: badDesktop.localDescription.sdp
|
||||
.split(/\r?\n/).map(line => line.startsWith('a=fingerprint:sha-256 ')
|
||||
? `a=fingerprint:sha-256 ${wrongFingerprint}` : line).join('\r\n') })
|
||||
const mismatchState = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error('No definitive certificate rejection')) }, 15000)
|
||||
const check = () => {
|
||||
const state = badPhone.sctp?.transport.state
|
||||
if (state === 'failed' || state === 'connected') { cleanup(); resolve(state) }
|
||||
}
|
||||
const cleanup = () => { clearTimeout(timeout); badPhone.removeEventListener('connectionstatechange', check) }
|
||||
badPhone.addEventListener('connectionstatechange', check); check()
|
||||
})
|
||||
return { fingerprints: certificate.getFingerprints(), expires: certificate.expires,
|
||||
nativeCertificate: certificate instanceof RTCCertificate, advertised, echo,
|
||||
dtls: goodDtls, mismatchState, mismatchOpened, missingRejected, wrongPinRejected, untrustedPeerRejected }
|
||||
} finally { for (const peer of peers) peer.close() }
|
||||
}, original.fingerprint)
|
||||
assert.equal(result.nativeCertificate, true)
|
||||
assert.deepEqual(result.fingerprints, original.fingerprints)
|
||||
assert.equal(result.expires, original.expires)
|
||||
assert.ok(result.expires > Date.now())
|
||||
const sha256 = original.fingerprints.find(item => item.algorithm === 'sha-256')
|
||||
assert.ok(sha256)
|
||||
assert.ok(result.advertised.some(line => line.toLowerCase() === `a=fingerprint:sha-256 ${sha256.value}`.toLowerCase()))
|
||||
assert.equal(result.echo, 'Türkçe kimlik doğrulama 🟢')
|
||||
assert.equal(result.dtls, 'connected')
|
||||
assert.equal(result.mismatchState, 'failed')
|
||||
assert.equal(result.mismatchOpened, false)
|
||||
assert.equal(result.missingRejected, true)
|
||||
assert.equal(result.wrongPinRejected, true)
|
||||
assert.equal(result.untrustedPeerRejected, true)
|
||||
console.log(JSON.stringify({ nativeCertificatePersistence: 'passed', reload: 'passed',
|
||||
restoredCertificateDtlsEcho: 'passed', nativeFingerprintMismatch: 'rejected',
|
||||
concurrentCreation: 'passed', missingKeyAndWrongPin: 'rejected', scope: 'identity module and native DTLS; not full app pairing' }))
|
||||
} finally {
|
||||
await browser?.close()
|
||||
server.close()
|
||||
}
|
||||
@@ -25,8 +25,74 @@ try {
|
||||
assert.ok(!blob.includes(Buffer.from('local-fake-account-for-encryption-test')))
|
||||
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
|
||||
assert.equal((await vault.load()).accountToken, 'local-fake-account-for-encryption-test')
|
||||
await vault.save({ ...await vault.load(), deviceId: '11111111-1111-4111-8111-111111111111' })
|
||||
const fingerprint = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { createDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||
const setup = await mobileHost.call('setup');
|
||||
const identity = await createDeviceIdentity(setup.deviceId);
|
||||
await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:identity.fingerprint});
|
||||
let rejected = false;
|
||||
try { await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:Array(32).fill('00').join(':')}); }
|
||||
catch { rejected = true; }
|
||||
if (!rejected) throw new Error('Changed identity silently accepted');
|
||||
return identity.fingerprint;
|
||||
})()`)
|
||||
assert.equal((await vault.load()).localFingerprint, fingerprint)
|
||||
const reloaded = new Promise(resolve => window.webContents.once('did-finish-load', resolve))
|
||||
window.webContents.reload(); await reloaded
|
||||
const restored = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { loadDeviceIdentity } = await import('./mobile/peer-identity.mjs');
|
||||
const setup = await mobileHost.call('setup');
|
||||
const identity = await loadDeviceIdentity(setup.deviceId, setup.localFingerprint);
|
||||
return identity.fingerprint;
|
||||
})()`)
|
||||
assert.equal(restored, fingerprint)
|
||||
const originalFetch = globalThis.fetch, phoneId = '33333333-3333-4333-8333-333333333333'
|
||||
const pairingId = '44444444-4444-4444-8444-444444444444', phonePin = Array(32).fill('AB').join(':')
|
||||
let advertisedPin = phonePin, approvals = 0
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const path = new URL(url).pathname
|
||||
if (path === `/v1/aiturk/pairings/${pairingId}/approve` && options.method === 'POST') {
|
||||
approvals++
|
||||
return Response.json({ device_id: phoneId, status: 'approved' })
|
||||
}
|
||||
if (path === '/v1/aiturk/mobile/channels') return Response.json({ channels: [{ id: 'fixture-channel',
|
||||
phone_id: phoneId, sdp: `v=0\r\na=fingerprint:sha-256 ${advertisedPin}\r\n` }] })
|
||||
throw new Error('Unexpected network request in isolated test')
|
||||
}
|
||||
try {
|
||||
const proof = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: phonePin })
|
||||
const qrImport = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { drawQr } = await import('./mobile/qr.mjs');
|
||||
const canvas = document.createElement('canvas'); drawQr(canvas, ${JSON.stringify(proof)});
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'phone-identity.png', {type:'image/png'}));
|
||||
const input = document.getElementById('phone-identity-file'); input.files = transfer.files;
|
||||
await input.onchange({target:input});
|
||||
return {pairingId:document.getElementById('pairing').value, proof:document.getElementById('phone-identity').value,
|
||||
status:document.getElementById('status').textContent};
|
||||
})()`)
|
||||
assert.equal(qrImport.pairingId, pairingId)
|
||||
assert.equal(qrImport.proof, proof)
|
||||
assert.match(qrImport.status, /henüz izin verilmedi/)
|
||||
assert.equal(approvals, 0)
|
||||
const accepted = await window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: proof })})`)
|
||||
assert.equal(accepted.device_id, phoneId)
|
||||
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||
const inbox = await window.webContents.executeJavaScript(`mobileHost.call('inbox')`)
|
||||
assert.equal(inbox.channels[0].trustedPhoneFingerprint, phonePin)
|
||||
advertisedPin = Array(32).fill('CD').join(':')
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('inbox')`), /kimliği/)
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('channel.answer', {channelId:'fixture-channel',sdp:'unused'})`), /kimliği/)
|
||||
const changed = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: advertisedPin })
|
||||
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: changed })})`), /kimliği/)
|
||||
assert.equal(approvals, 1)
|
||||
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
|
||||
} finally { globalThis.fetch = originalFetch }
|
||||
await vault.save({})
|
||||
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
|
||||
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', desktopIdentityReload: 'passed',
|
||||
silentIdentityReplacement: 'rejected', locallyApprovedPhonePin: 'passed',
|
||||
changedPhoneBeforeAnswer: 'rejected', identityQrImportWithoutApproval: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
|
||||
await writeFile('apps/mobile/test-results/electron-smoke.json', JSON.stringify(receipt))
|
||||
console.log(JSON.stringify(receipt))
|
||||
window.destroy(); app.exit(0)
|
||||
|
||||
+53
-9
@@ -1,14 +1,45 @@
|
||||
import { AccountClient, openPhoneConnection } from './transport.mjs'
|
||||
import { VoiceSession } from './voice.mjs'
|
||||
import { DEFAULT_VOICE_LIMITS, voiceLimits, usageLabel } from './voice-usage.mjs'
|
||||
import { taskState } from './behavior.mjs'
|
||||
import { parseInvite } from './invite.mjs'
|
||||
import { InviteScanner, drawQr } from './qr.mjs'
|
||||
import { createDeviceIdentity } from './peer-identity.mjs'
|
||||
const $ = id => document.getElementById(id)
|
||||
if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets')
|
||||
let credentials = {}, account, connection, selected, approval, voice, pendingTask, usageTimer, voiceGeneration = 0
|
||||
let credentials = {}, account, connection, selected, approval, voice, pendingTask, voiceGeneration = 0
|
||||
let connectedDesktopId = null
|
||||
let connectionEpoch = 0, selectionEpoch = 0
|
||||
const status = text => { $('status').textContent = text }
|
||||
const native = () => window.Capacitor?.Plugins?.MobileSecrets
|
||||
const scanner = new InviteScanner($('qr-video'), async raw => {
|
||||
if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; kayıtlı cihaz varsa önce mevcut eşleşmeyi kontrol edin.')
|
||||
const targetAccount = account, epoch = connectionEpoch
|
||||
const invite = parseInvite(raw)
|
||||
const result = await account.call(`/v1/aiturk/mobile/invites/${invite.inviteId}/claim`, {
|
||||
secret: invite.secret, display_name: $('device-name').value.trim() })
|
||||
if (account !== targetAccount || epoch !== connectionEpoch) return
|
||||
const identity = await createDeviceIdentity(result.device_id)
|
||||
if (account !== targetAccount || epoch !== connectionEpoch) return
|
||||
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token,
|
||||
pairingId: result.pairing_id, preferredDesktopId: invite.desktopId,
|
||||
desktopFingerprints: { [invite.desktopId]: invite.desktopFingerprint }, localFingerprint: identity.fingerprint })
|
||||
await saveCredentials()
|
||||
showPhoneIdentity()
|
||||
$('pair-code').textContent = `Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: ${result.user_code}`
|
||||
status('QR okundu. Bilgisayardaki açık telefon onayı bekleniyor.')
|
||||
})
|
||||
scanner.onError = error => status(error.message)
|
||||
action('scan-invite', async () => { if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; bu telefon henüz eşleştirilmemiş olmalı.'); await scanner.start() })
|
||||
action('stop-scan', async () => scanner.stop())
|
||||
$('qr-file').onchange = async event => { try { await scanner.image(event.target.files[0]) } catch (error) { status(error.message) } finally { event.target.value = '' } }
|
||||
async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) }
|
||||
function showPhoneIdentity() {
|
||||
$('phone-identity').value = credentials.localFingerprint ? JSON.stringify({ kind: 'aiturk-phone-identity', version: 1,
|
||||
deviceId: credentials.deviceId, pairingId: credentials.pairingId, fingerprint: credentials.localFingerprint }) : ''
|
||||
$('phone-identity-qr').hidden = !$('phone-identity').value
|
||||
if ($('phone-identity').value) drawQr($('phone-identity-qr'), $('phone-identity').value)
|
||||
}
|
||||
function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() }
|
||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||
function rpc(action, params) { if (!connection) throw new Error('Önce bilgisayara bağlanın.'); return connection.requests.call(action, params) }
|
||||
@@ -41,6 +72,7 @@ action('login-start', async () => {
|
||||
status('Tarayıcıda giriş yaptıktan sonra buraya dönüp girişi kontrol edin.')
|
||||
})
|
||||
action('login-check', async () => {
|
||||
if (credentials.accountToken) throw new Error('Hesaba zaten giriş yapılmış; mevcut hesap değiştirilmeyecek.')
|
||||
if (!credentials.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||
const result = await new AccountClient({ accountToken: credentials.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||
if (result.state !== 'approved') { status('Tarayıcıdaki kod onayı bekleniyor.'); return }
|
||||
@@ -49,12 +81,14 @@ action('login-check', async () => {
|
||||
status('Hesap bağlı. Şimdi telefonun eşleştirme kodunu oluşturun.')
|
||||
})
|
||||
action('logout', async () => {
|
||||
scanner.stop()
|
||||
const token = credentials.accountToken || credentials.pendingLogin?.deviceCode
|
||||
// Revoke the app-specific credential before dropping the local copy. A
|
||||
// network failure leaves it available for an explicit retry.
|
||||
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
|
||||
connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; account = null
|
||||
credentials = {}; pendingTask = null; await saveCredentials()
|
||||
showPhoneIdentity()
|
||||
$('work').hidden = true; $('login-link').hidden = true; $('login-code').textContent = ''; $('pair-code').textContent = ''
|
||||
status('Bu uygulamanın oturumu kapatıldı. IDE’de başlamış iş kendi çalışma kurallarıyla devam edebilir.')
|
||||
})
|
||||
@@ -66,8 +100,11 @@ action('pair', async () => {
|
||||
if (!credentials.accountToken) throw new Error('Önce AITURK hesabınıza giriş yapın.')
|
||||
account = new AccountClient(credentials)
|
||||
const result = await account.call('/v1/aiturk/pairings', { display_name: $('device-name').value.trim(), platform: 'android' })
|
||||
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id })
|
||||
const identity = await createDeviceIdentity(result.device_id)
|
||||
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id,
|
||||
localFingerprint: identity.fingerprint })
|
||||
await saveCredentials()
|
||||
showPhoneIdentity()
|
||||
$('pair-code').textContent = `Eşleştirme kimliği:\n${result.pairing_id}\n\nKod: ${result.user_code}\nSon geçerlilik: ${result.expires_at}`
|
||||
status('Bilgisayardaki açık onay bekleniyor.')
|
||||
})
|
||||
@@ -109,14 +146,18 @@ action('select-session', async () => {
|
||||
for (const [id, choice] of [['allow', 'once'], ['deny', 'deny']]) action(id, async () => { const target = requireSession(); await rpc('approval.respond', { sessionId: target.id, requestId: approval?.requestId, choice }); setApproval(null); status('Yanıt gerçek IDE onayına iletildi.') })
|
||||
action('save-google', async () => { const key = $('google-key').value.trim(); $('google-key').value = ''; await rpc('google.key.save', { key }); status('Google anahtarı bilgisayarın güvenli kasasına kaydedildi.') })
|
||||
action('delete-google', async () => { stopVoice(); await rpc('google.key.delete'); status('Google anahtarı bilgisayar kasasından silindi.') })
|
||||
function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; clearInterval(usageTimer); $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' }
|
||||
function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' }
|
||||
async function startVoice(microphone) {
|
||||
if (!$('consent').checked) throw new Error('Önce Google veri ve ücret bilgisini okuyup kabul edin.')
|
||||
const limits = voiceLimits({ minutes: $('voice-minutes').value, usd: $('voice-budget').value })
|
||||
// Only non-secret preferences; account and API credentials remain in the
|
||||
// native vault. No usage history or transcripts are stored here.
|
||||
try { localStorage.setItem('aiturk.voice-limits', JSON.stringify(limits)) } catch {}
|
||||
const target = { ...requireSession() }, targetConnection = connection, desktopId = connectedDesktopId
|
||||
stopVoice(); const generation = voiceGeneration; const token = await rpc('voice.token')
|
||||
if (generation !== voiceGeneration || document.hidden) return
|
||||
const started = Date.now()
|
||||
voice = new VoiceSession({ ...token,
|
||||
voice = new VoiceSession({ ...token, limits,
|
||||
onUsage: usage => { if (generation === voiceGeneration) $('usage').textContent = usageLabel(usage, usage.seconds) },
|
||||
onText: (role, text) => addText('discussion', role, text),
|
||||
onDraft: (text, intent) => { $('task').value = text; $('intent').value = intent; updatePreview(); status('Asistan görev taslağını hazırladı. Henüz IDE’ye gönderilmedi.') },
|
||||
onSubmit: async (text, intent) => {
|
||||
@@ -127,8 +168,7 @@ async function startVoice(microphone) {
|
||||
pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId, intent, text }
|
||||
return deliver()
|
||||
},
|
||||
onError: status, onState: text => { $('voice-status').textContent = text; if (voice?.stopped) clearInterval(usageTimer) } })
|
||||
usageTimer = setInterval(() => { $('usage').textContent = `Bu uygulamanın ölçtüğü görüşme süresi: ${Math.floor((Date.now() - started) / 1000)} sn. Google kotası/faturası değildir.` }, 1000)
|
||||
onError: status, onState: text => { $('voice-status').textContent = text } })
|
||||
await voice.start(microphone)
|
||||
}
|
||||
action('voice-start', () => startVoice(true)); action('voice-text', () => startVoice(false))
|
||||
@@ -166,6 +206,10 @@ action('history', async () => { const result = await rpc('session.history', { se
|
||||
action('clear-history', async () => { $('discussion').replaceChildren(); $('result').replaceChildren(); status('Telefondaki görünür metin temizlendi. Google bağlantı bağlamını da temizlemek için görüşmeyi kapatın.') })
|
||||
action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; $('work').hidden = true; status('Telefon bağlantısı kapalı. IDE işi devam edebilir.') })
|
||||
action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') })
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) stopVoice() })
|
||||
window.addEventListener('pagehide', () => { stopVoice(); connection?.close() })
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) { stopVoice(); scanner.stop() } })
|
||||
window.addEventListener('pagehide', () => { stopVoice(); scanner.stop(); connection?.close() })
|
||||
let savedLimits = DEFAULT_VOICE_LIMITS
|
||||
try { const raw = localStorage.getItem('aiturk.voice-limits'); if (raw) savedLimits = voiceLimits(JSON.parse(raw)) } catch {}
|
||||
$('voice-minutes').value = savedLimits.minutes; $('voice-budget').value = savedLimits.usd
|
||||
try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') }
|
||||
showPhoneIdentity()
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
<label>AITURK hesap API anahtarı<input id="account-token" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
|
||||
</details>
|
||||
<label>Telefon adı<input id="device-name" maxlength="80" value="Android telefonum"></label><button id="pair">Eşleştirme kodu oluştur</button>
|
||||
<p>Bilgisayarda “Telefon için QR oluştur” seçin. Giriş yaptıktan sonra buradan tarayın. Görüntü sunucuya yüklenmez.</p>
|
||||
<button id="scan-invite">Bilgisayarın QR kodunu tara</button><button id="stop-scan" class="secondary">Kamerayı kapat</button>
|
||||
<video id="qr-video" hidden muted playsinline aria-label="QR kamera önizlemesi"></video>
|
||||
<label>Alternatif: QR resmi seç<input id="qr-file" type="file" accept="image/*"></label>
|
||||
<pre id="pair-code" aria-live="polite"></pre><p>Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.</p>
|
||||
<label>Bu telefonun kimlik belgesi<textarea id="phone-identity" readonly aria-describedby="phone-identity-help"></textarea></label>
|
||||
<canvas id="phone-identity-qr" hidden aria-label="Telefonun sertifika kimliği QR kodu"></canvas>
|
||||
<p>Kimlik QR kodunun ekran görüntüsünü kendi bilgisayarınıza aktarabilir ve bilgisayarda “Telefon kimliği QR resmi” alanından açabilirsiniz. Onayı ayrıca bilgisayarda verin.</p>
|
||||
<p id="phone-identity-help">Bu belgenin tamamını kendi bilgisayarınızdaki “Telefondan alınan kimlik belgesi” alanına aktarın. Bu, özel anahtar değil; onaylayacağınız telefonun sertifika kimliğidir. Başkasının gönderdiği belgeyi onaylamayın.</p>
|
||||
<button id="devices" class="secondary">Onaydan sonra bilgisayarları bul</button>
|
||||
<label>Bilgisayar<select id="desktop"><option value="">Önce cihazları bulun</option></select></label><button id="connect">Bilgisayara bağlan</button>
|
||||
</section><section id="work" hidden><h2>2. IDE konuşması</h2><button id="sessions" class="secondary">Konuşmaları yenile</button>
|
||||
@@ -24,9 +32,15 @@
|
||||
<p><a href="https://aistudio.google.com/apikey" target="_blank" rel="noreferrer">Kendi Google AI Studio anahtarınızı oluşturun</a> · <a href="https://ai.google.dev/gemini-api/docs/pricing" target="_blank" rel="noreferrer">Güncel fiyat ve veri koşulları</a></p>
|
||||
<label>Google AI Studio API anahtarı<input id="google-key" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label><button id="save-google">Bilgisayarın güvenli kasasına kaydet</button><button id="delete-google" class="secondary">Google anahtarını sil</button>
|
||||
<label><input id="consent" type="checkbox">Google'a veri aktarımını ve projemde ücret oluşabileceğini anladım.</label>
|
||||
<fieldset><legend>Her yeni ses / yazışma oturumu için durdurma sınırı</legend>
|
||||
<label>En fazla dakika (1–10)<input id="voice-minutes" type="number" min="1" max="10" step="1" value="10" inputmode="numeric"></label>
|
||||
<label>Tahmini maliyet sınırı (USD)<input id="voice-budget" type="number" min="0.01" max="10" step="0.01" value="0.25" inputmode="decimal"></label>
|
||||
<p>Sınırlar bir sonraki bağlantıda uygulanır; her yeni bağlantıda sayaç sıfırlanır. Tahmin ücretli tarifeyi kullanır; ücretsiz kotanız ölçülmez. Görüşme geçmişi ve metin tokenları da ücret oluşturabilir. Bildirim gecikirse veya tarife değişirse gerçek ücret sınırı aşabilir. İki dakika kullanım bildirimi gelmezse bağlantı kapanır. Otomatik yeniden bağlanılmaz; IDE işi durdurulmaz.</p>
|
||||
</fieldset>
|
||||
<div class="row"><button id="voice-start">Sesle konuş</button><button id="voice-text" class="secondary">Asistanla yazış</button></div>
|
||||
<div class="row"><button id="voice-stop" class="secondary">Ses görüşmesini kapat</button><button id="silence" class="secondary">Asistanın sesini kes</button></div>
|
||||
<p id="voice-status" role="status">Mikrofon kapalı.</p><small id="usage">Yerel süre: 0 sn. Google kalan kotası ölçülmüyor.</small>
|
||||
<p id="voice-status" role="status">Mikrofon kapalı.</p><small id="usage">Henüz kullanım yok. Google kalan kotası ve faturası ölçülmüyor.</small>
|
||||
<p><small>Tahmin tarifesi: Gemini 3.1 Flash Live Preview · 8 Eylül 2026. Vergi, indirim ve bildirilmemiş kullanım dahil değildir.</small></p>
|
||||
<div id="discussion" class="history" aria-label="Google asistanı görüşmesi"></div>
|
||||
<label>Asistanla fikrini geliştir<textarea id="idea" placeholder="Bu uygulamayı nasıl geliştirebiliriz?"></textarea></label><button id="discuss">Asistana yaz</button>
|
||||
<h2>IDE'ye gönderilecek görev</h2><p>Fikir konuşması kendi başına iş başlatmaz. Aşağıdaki metnin tamamı seçili IDE konuşmasına gönderilir. Araştırma seçeneği değişiklik izni vermez.</p>
|
||||
@@ -39,4 +53,4 @@
|
||||
<button id="revoke" class="danger">Bu telefonun erişimini iptal et</button>
|
||||
</section><hr><p>Ses kaydı saklanmaz. Google görüşme metni yalnız bu ekranın belleğinde kalır. IDE'ye gönderilen görev IDE geçmişinde saklanır. Ekrandan çıkmak mikrofonu kapatır; IDE işini durdurmaz.</p>
|
||||
<p><a href="privacy.html">Veri akışı ve geliştirme sürümünün sınırları</a></p>
|
||||
</main><script src="capacitor.js"></script><script type="module" src="app.mjs"></script></body></html>
|
||||
</main><script src="capacitor.js"></script><script src="vendor/jsQR.js"></script><script type="module" src="app.mjs"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { normalizeFingerprint } from './peer-identity.mjs'
|
||||
|
||||
export function parseInvite(raw, now = Date.now()) {
|
||||
if (typeof raw !== 'string' || raw.length > 800) throw new Error('Bu bir AITURK eşleştirme QR kodu değil.')
|
||||
let data
|
||||
try { data = JSON.parse(raw) } catch { throw new Error('Bu bir AITURK eşleştirme QR kodu değil.') }
|
||||
if (!data || data.kind !== 'aiturk-mobile-invite' || data.version !== 2 ||
|
||||
!/^[0-9a-f-]{36}$/.test(data.inviteId) || !/^[0-9a-f-]{36}$/.test(data.desktopId) ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(data.secret) || !Number.isFinite(Date.parse(data.expiresAt)) ||
|
||||
Date.parse(data.expiresAt) <= now || Date.parse(data.expiresAt) > now + 11 * 60000)
|
||||
throw new Error('QR geçersiz veya süresi dolmuş. Bilgisayarda yeni kod oluşturun.')
|
||||
return { inviteId: data.inviteId, desktopId: data.desktopId, secret: data.secret,
|
||||
desktopFingerprint: normalizeFingerprint(data.desktopFingerprint) }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Standard WebRTC identities; private key material stays inside RTCCertificate.
|
||||
// Pairing must persist the public fingerprint separately in its trusted record.
|
||||
const DATABASE = 'aiturk-mobile-peer-identities-v1'
|
||||
const STORE = 'certificates'
|
||||
const failure = () => new Error('Cihaz kimliği doğrulanamadı. Mevcut eşleşmeyi kullanmayın; yeniden açık eşleştirme gerekli.')
|
||||
|
||||
function deviceKey(deviceId) {
|
||||
if (typeof deviceId !== 'string' || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(deviceId)) throw failure()
|
||||
return deviceId.toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizeFingerprint(value) {
|
||||
if (typeof value !== 'string' || !/^(?:[0-9a-f]{2}:){31}[0-9a-f]{2}$/i.test(value)) throw failure()
|
||||
return value.toUpperCase()
|
||||
}
|
||||
|
||||
export function parsePhoneIdentity(raw, pairingId) {
|
||||
if (typeof raw !== 'string' || raw.length > 700) throw failure()
|
||||
let value
|
||||
try { value = JSON.parse(raw) } catch { throw failure() }
|
||||
if (value?.kind !== 'aiturk-phone-identity' || value.version !== 1 || value.pairingId !== pairingId) throw failure()
|
||||
return { deviceId: deviceKey(value.deviceId), fingerprint: normalizeFingerprint(value.fingerprint) }
|
||||
}
|
||||
|
||||
function identity(certificate) {
|
||||
if (!(certificate instanceof RTCCertificate) || !Number.isFinite(certificate.expires) || certificate.expires <= Date.now()) throw failure()
|
||||
const fingerprints = certificate.getFingerprints().filter(item => item.algorithm === 'sha-256')
|
||||
if (fingerprints.length !== 1) throw failure()
|
||||
return { certificate, fingerprint: normalizeFingerprint(fingerprints[0].value), expires: certificate.expires }
|
||||
}
|
||||
|
||||
async function database() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let blocked = false
|
||||
const request = indexedDB.open(DATABASE, 1)
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE)
|
||||
request.onerror = () => reject(failure())
|
||||
request.onblocked = () => { blocked = true; reject(failure()) }
|
||||
request.onsuccess = () => {
|
||||
if (blocked) { request.result.close(); return }
|
||||
request.result.onversionchange = () => request.result.close()
|
||||
resolve(request.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Call only during an explicit NEW device pairing, never to repair a missing key.
|
||||
// The read/write transaction chooses one winner across simultaneous windows.
|
||||
export async function createDeviceIdentity(deviceId) {
|
||||
const key = deviceKey(deviceId)
|
||||
const candidate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
|
||||
const db = await database()
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite'), store = tx.objectStore(STORE)
|
||||
let selected
|
||||
const read = store.get(key)
|
||||
read.onsuccess = () => {
|
||||
try {
|
||||
selected = identity(read.result === undefined ? candidate : read.result)
|
||||
if (read.result === undefined) store.add(candidate, key)
|
||||
} catch { tx.abort() }
|
||||
}
|
||||
tx.oncomplete = () => resolve(selected)
|
||||
tx.onabort = tx.onerror = () => reject(failure())
|
||||
})
|
||||
} finally { db.close() }
|
||||
}
|
||||
|
||||
// Never generates or replaces a certificate. A lost key or changed public pin
|
||||
// requires a new explicit pairing, not trust-on-first-use during reconnect.
|
||||
export async function loadDeviceIdentity(deviceId, expectedFingerprint) {
|
||||
const key = deviceKey(deviceId), expected = normalizeFingerprint(expectedFingerprint)
|
||||
const db = await database()
|
||||
try {
|
||||
const certificate = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE), request = tx.objectStore(STORE).get(key)
|
||||
tx.oncomplete = () => resolve(request.result)
|
||||
tx.onabort = tx.onerror = () => reject(failure())
|
||||
})
|
||||
const result = identity(certificate)
|
||||
if (result.fingerprint !== expected) throw failure()
|
||||
return result
|
||||
} finally { db.close() }
|
||||
}
|
||||
|
||||
// Application data channels use a single bundled DTLS identity. Check every
|
||||
// advertised fingerprint before setRemoteDescription; native DTLS then verifies
|
||||
// that the actual peer possesses the matching private key.
|
||||
export function assertRemoteIdentity(sdp, trustedFingerprint) {
|
||||
const expected = normalizeFingerprint(trustedFingerprint)
|
||||
if (typeof sdp !== 'string' || sdp.length > 131072) throw failure()
|
||||
const lines = sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
|
||||
if (!lines.length) throw failure()
|
||||
for (const line of lines) {
|
||||
const match = /^a=fingerprint:sha-256 ((?:[0-9a-f]{2}:){31}[0-9a-f]{2})$/i.exec(line)
|
||||
if (!match || normalizeFingerprint(match[1]) !== expected) throw failure()
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
<p class="eyebrow">AITURK MOBİL · 8 EYLÜL 2026</p><h1>Verilerinizin yolu.</h1>
|
||||
<p>Bu geliştirme sürümü Android için hazırlanmıştır. Gerçek telefon, ayrı internet ağları ve Google ses görüşmesi kabul testleri tamamlanmadan genel kullanıma hazır sayılmaz.</p>
|
||||
<h2>Hesap ve cihazlar</h2><p>AITURK hesap API anahtarı ve iptal edilebilir cihaz sırrı telefonda Android Keystore ile, bilgisayarda Windows güvenli kasasıyla korunur. Eşleştirme kodları sürelidir; telefon bu bilgisayarda açıkça onaylanır. Cihaz iptali yeni işlemleri engeller; açık bağlantı da en geç bir sonraki yetki kontrolünde kapanır.</p>
|
||||
<h2>Telefon ve bilgisayar</h2><p>Görev ve anahtar aktarımı WebRTC DTLS veri kanalından geçer. AITURK bağlantı hizmeti hesap/cihaz kimliklerini ve ağ bağlantı açıklamalarını (SDP, IP adresleri dahil) görür. Kayıtlar 90 saniye içinde yenilenmezse sona erer ve sonraki bağlantı sorgusunda temizlenir. Hizmet görev veya Google anahtarını saklamaz. Sinyalleşme hizmetine eş kimliğini doğru doğrulaması için güvenilir; kötü niyetli sinyalleşme sunucusuna karşı ayrıca doğrulanmış parmak izi koruması henüz yoktur.</p>
|
||||
<h2>Telefon ve bilgisayar</h2><p>Görev ve anahtar aktarımı WebRTC DTLS veri kanalından geçer. AITURK bağlantı hizmeti hesap/cihaz kimliklerini ve ağ bağlantı açıklamalarını (SDP, IP adresleri dahil) görür. Kayıtlar 90 saniye içinde yenilenmezse sona erer ve sonraki bağlantı sorgusunda temizlenir. Hizmet görev veya Google anahtarını saklamaz. Cihaz sertifikaları yerel tarayıcı veritabanında; güvenilen karşı cihaz parmak izleri Android/Windows kasasında saklanır. Telefon bilgisayar kimliğini QR'dan, bilgisayar telefon kimliğini sizin aktarıp onayladığınız belgeden alır. Bağlantı bu kimliklerle karşılaştırılır; değişen veya kayıp kimlik yeniden açık eşleştirme gerektirir. Başkasının gönderdiği QR veya kimlik belgesini onaylamayın. Bu koruma ele geçirilmiş telefon/bilgisayarın güvenliğini sağlamaz; gerçek cihaz kabulü henüz tamamlanmadı.</p>
|
||||
<h2>Google ses asistanı</h2><p>Kalıcı Google anahtarınız yalnız eşleştirdiğiniz bilgisayarda saklanır. Telefon kısa ömürlü erişimle doğrudan Google'a bağlanır. Ses ve Google görüşme metni Google tarafından işlenir; telefon-bilgisayar şifrelemesi Google'dan gizlilik sağlamaz. Ücretsiz katman verileri ürün geliştirme için kullanılabilir. Ücretli Google projesindeki çağrılar ücretlidir. Güncel koşullar için Google AI Studio ve Gemini API fiyat sayfasını okuyun.</p>
|
||||
<h2>Geçmiş ve silme</h2><p>Ses kayıtları bu uygulama tarafından dosyaya yazılmaz. Görüşme metni ekranda geçici bellektedir. Temizleme düğmesi yerel görünümü temizler; aktif Google oturumunun bağlamı görüşme kapandığında bırakılır. IDE'ye gönderdiğiniz görev IDE'nin kendi geçmiş ve saklama kurallarına tabidir. Görev kimliği, içerik özeti ve gönderim durumu tekrar çalıştırmayı önlemek için bilgisayarda saklanır; görev metni bu kayıtta tutulmaz.</p>
|
||||
<h2>Bilinen sınırlar</h2><p>TURN hizmeti henüz sağlanmadığından bazı farklı ağlarda bağlantı kurulamaz. QR tarama, tek dokunuşla hesap girişi, doğrulanmış maliyet tahmini, otomatik sesli görev yürütme ve gerçek cihaz değerlendirmesi henüz tamamlanmamıştır. Ses aracı şu anda görünür görev taslağı üretir; kullanıcı gönder düğmesiyle IDE'ye iletir. Bu sayfa eksik özellikleri çalışıyor olarak sunmaz.</p>
|
||||
<h2>Maliyet kontrolü</h2><p>Google'ın bildirdiği tokenlar ücretli liste tarifesiyle tahmin edilir. Geçmiş bağlamı tekrar ücretlenebilir. Eksik ayrıntılar fiyat aralığıyla gösterilir; bildirilmemiş kullanım, vergi ve indirimler dahil değildir. Seçtiğiniz süre veya tahmini sınır dolduğunda yalnız Google bağlantısı kapanır; IDE işi devam edebilir. İki dakika kullanım bildirimi gelmezse bağlantı kapatılır. Bildirim gecikmesi ve tarife değişiklikleri nedeniyle gerçek ücret sınırı aşabilir. Her yeni oturumda sayaç sıfırlanır. Yalnız süre ve sınır tercihi cihazda saklanır; burada fatura ya da kalan ücretsiz kota ölçülmez.</p>
|
||||
<h2>Bilinen sınırlar</h2><p>QR ve tarayıcı üzerinden kısa kodla hesap girişi geliştirildi; canlı hizmete dağıtılmadı. Asistan görünür taslak hazırlayıp açık uygulama/araştırma talimatıyla seçili IDE konuşmasına görev iletebilir; işlem onaylarını kullanıcı verir. Bu davranış, maliyet sınırı ve bağlantı akışı sentetik yanıtlarla test edildi. Gerçek Android/Google kalite, fatura ve ayrı ağ kabulü henüz yapılmadı. TURN dağıtımı ve kalıcı eş kimliği doğrulaması tamamlanmadan genel kullanıma hazır değildir.</p>
|
||||
<a href="index.html">Uygulamaya dön</a></main></body></html>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import qrcode from './vendor/qrcode.mjs'
|
||||
|
||||
export function drawQr(canvas, text) {
|
||||
const qr = qrcode(0, 'M'); qr.addData(text); qr.make()
|
||||
const count = qr.getModuleCount(), scale = 5, margin = 4
|
||||
canvas.width = canvas.height = (count + margin * 2) * scale
|
||||
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = '#000'
|
||||
for (let y = 0; y < count; y++) for (let x = 0; x < count; x++)
|
||||
if (qr.isDark(y, x)) ctx.fillRect((x + margin) * scale, (y + margin) * scale, scale, scale)
|
||||
}
|
||||
|
||||
export class InviteScanner {
|
||||
constructor(video, onCode) { this.video = video; this.onCode = onCode; this.epoch = 0; this.canvas = document.createElement('canvas') }
|
||||
decode(source, width, height) {
|
||||
if (!width || !height) return null
|
||||
const scale = Math.min(1, 1280 / Math.max(width, height))
|
||||
this.canvas.width = Math.round(width * scale); this.canvas.height = Math.round(height * scale)
|
||||
const ctx = this.canvas.getContext('2d', { willReadFrequently: true })
|
||||
ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height)
|
||||
const pixels = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
|
||||
return globalThis.jsQR(pixels.data, pixels.width, pixels.height)?.data
|
||||
}
|
||||
async image(file) {
|
||||
this.stop(); const epoch = this.epoch
|
||||
if (!file || file.size > 8 * 1024 * 1024) throw new Error('QR resmi en fazla 8 MB olabilir.')
|
||||
const bitmap = await createImageBitmap(file)
|
||||
let result
|
||||
try { result = this.decode(bitmap, bitmap.width, bitmap.height) } finally { bitmap.close() }
|
||||
if (epoch !== this.epoch) return
|
||||
if (!result) throw new Error('Resimde QR okunamadı; daha net bir görüntü deneyin.')
|
||||
await this.onCode(result)
|
||||
}
|
||||
async start() {
|
||||
this.stop(); const epoch = this.epoch
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
||||
if (epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); return }
|
||||
this.stream = stream; this.video.srcObject = stream; this.video.hidden = false
|
||||
try {
|
||||
await this.video.play()
|
||||
if (epoch !== this.epoch) return
|
||||
this.timer = setInterval(() => {
|
||||
const result = this.decode(this.video, this.video.videoWidth, this.video.videoHeight)
|
||||
if (result) { this.stop(); void this.onCode(result).catch(error => this.onError?.(error)) }
|
||||
}, 250)
|
||||
this.limit = setTimeout(() => this.stop(), 60000)
|
||||
} catch (error) { this.stop(); throw error }
|
||||
}
|
||||
stop() { this.epoch++; clearInterval(this.timer); clearTimeout(this.limit); this.stream?.getTracks().forEach(t => t.stop()); this.stream = null; this.video.srcObject = null; this.video.hidden = true }
|
||||
}
|
||||
@@ -8,3 +8,5 @@ button.secondary { background:var(--surface); color:var(--text); border:1px soli
|
||||
:focus-visible { outline:3px solid var(--accent); outline-offset:4px } a { color:var(--accent) } hr { border:0; border-top:1px solid var(--stroke); margin:30px 0 } pre { white-space:pre-wrap; word-break:break-word; font:inherit } [hidden] { display:none!important }
|
||||
.status { border-left:3px solid var(--accent); padding:10px 14px; color:var(--text); background:var(--surface) } .row { display:flex; align-items:center; flex-wrap:wrap; gap:4px } .row button { flex:1 } .history { max-height:340px; overflow:auto; padding:8px 0 } .history p { white-space:pre-wrap; overflow-wrap:anywhere } small { color:var(--muted) } input[type=checkbox] { width:24px; height:24px; vertical-align:middle; margin:0 8px 0 0 }
|
||||
@media(prefers-reduced-motion:no-preference) { button { transition:background-color .1s } }
|
||||
canvas,video{max-width:100%;height:auto}video{border-radius:12px}canvas{image-rendering:pixelated}
|
||||
fieldset { min-width:0; border:1px solid var(--stroke); border-radius:8px; margin:22px 0; padding:14px } legend { max-width:100%; padding:0 6px; font-weight:600 }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeFingerprint, assertRemoteIdentity, loadDeviceIdentity } from './peer-identity.mjs'
|
||||
export const GATEWAY = 'https://turkservis.online'
|
||||
async function accountFetch(url, options) {
|
||||
const plugin = globalThis.Capacitor?.Plugins?.MobileSecrets
|
||||
@@ -103,10 +104,21 @@ export function rtcConfiguration(iceServers = []) {
|
||||
}
|
||||
|
||||
export async function openPhoneConnection(account, desktopId, onEvent, iceServers = []) {
|
||||
// Never obtain this trust anchor from the signaling response. It came from
|
||||
// the QR physically displayed by the desktop and is stored in the phone vault.
|
||||
const trusted = account.credentials?.desktopFingerprints
|
||||
if (!trusted || !Object.hasOwn(trusted, desktopId))
|
||||
throw new Error('Bu bilgisayarın güvenilen cihaz kimliği yok. Bilgisayarda yeni QR oluşturup yeniden eşleştirin.')
|
||||
const fingerprint = normalizeFingerprint(trusted[desktopId])
|
||||
const identity = await loadDeviceIdentity(account.credentials.deviceId, account.credentials.localFingerprint)
|
||||
if (!iceServers.length) iceServers = (await account.call('/v1/aiturk/mobile/ice')).iceServers
|
||||
const pc = new RTCPeerConnection(rtcConfiguration(iceServers))
|
||||
const pc = new RTCPeerConnection({ ...rtcConfiguration(iceServers), certificates: [identity.certificate] })
|
||||
const channel = pc.createDataChannel('aiturk-mobile-v1', { ordered: true })
|
||||
const requests = new DataRequests(channel, onEvent)
|
||||
let established = false
|
||||
const requests = new DataRequests(channel, frame => {
|
||||
// Failed handshakes surface their actual error, not a later generic close.
|
||||
if (established || frame.event !== 'disconnected') onEvent(frame)
|
||||
})
|
||||
const id = crypto.randomUUID()
|
||||
let heartbeat, closed = false
|
||||
const close = () => { closed = true; clearInterval(heartbeat); channel.close(); pc.close() }
|
||||
@@ -115,7 +127,10 @@ export async function openPhoneConnection(account, desktopId, onEvent, iceServer
|
||||
await account.call('/v1/aiturk/mobile/channels', { channel_id: id, desktop_id: desktopId, sdp: pc.localDescription.sdp })
|
||||
for (let attempt = 0; attempt < 35 && !closed; attempt++) {
|
||||
const answer = await account.call(`/v1/aiturk/mobile/channels/${id}`)
|
||||
if (answer.sdp) { await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); break }
|
||||
if (answer.sdp) {
|
||||
assertRemoteIdentity(answer.sdp, fingerprint)
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); break
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -125,6 +140,7 @@ export async function openPhoneConnection(account, desktopId, onEvent, iceServer
|
||||
channel.addEventListener('close', () => { clearTimeout(timer); reject(new Error('Bağlantı kapandı.')) }, { once: true })
|
||||
})
|
||||
heartbeat = setInterval(() => { void account.call(`/v1/aiturk/mobile/channels/${id}/heartbeat`, {}).catch(close) }, 15000)
|
||||
established = true
|
||||
return { requests, close, pc, id }
|
||||
} catch (error) { close(); throw error }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Paid-list-price estimate, not a quota lookup or an invoice. Sources checked
|
||||
// 2026-09-08: https://ai.google.dev/gemini-api/docs/pricing and /api/live.
|
||||
// Live prompt counts include history billed again on each generation request.
|
||||
export const VOICE_PRICING = Object.freeze({
|
||||
model: 'gemini-3.1-flash-live-preview', checked: '2026-09-08',
|
||||
input: Object.freeze({ TEXT: 0.75, AUDIO: 3, IMAGE: 1, VIDEO: 1 }),
|
||||
output: Object.freeze({ TEXT: 4.5, AUDIO: 12 }),
|
||||
})
|
||||
export const DEFAULT_VOICE_LIMITS = Object.freeze({ minutes: 10, usd: 0.25 })
|
||||
|
||||
export function voiceLimits(value = DEFAULT_VOICE_LIMITS) {
|
||||
const minutes = Number(value.minutes), usd = Number(value.usd)
|
||||
// Ephemeral credentials currently expire after ten minutes; don't offer a
|
||||
// duration the connection cannot actually support.
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 10 ||
|
||||
!Number.isFinite(usd) || usd < 0.01 || usd > 10)
|
||||
throw new Error('Ses sınırı 1–10 dakika ve 0,01–10 USD arasında olmalı.')
|
||||
return { minutes, usd }
|
||||
}
|
||||
|
||||
function count(value) {
|
||||
if (value === undefined) return 0 // protobuf omits zero-valued fields
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Geçersiz kullanım sayacı.')
|
||||
return value
|
||||
}
|
||||
|
||||
function modalityCost(total, details, rates) {
|
||||
if (details !== undefined && !Array.isArray(details)) throw new Error('Geçersiz kullanım ayrıntısı.')
|
||||
let tokens = 0, low = 0, high = 0, uncertain = false
|
||||
const minimum = Math.min(...Object.values(rates)), maximum = Math.max(...Object.values(rates))
|
||||
for (const detail of details || []) {
|
||||
const n = count(detail?.tokenCount), rate = Object.hasOwn(rates, detail?.modality) ? rates[detail.modality] : undefined
|
||||
tokens += n; low += n * (rate ?? minimum); high += n * (rate ?? maximum)
|
||||
if (rate === undefined && n) uncertain = true
|
||||
}
|
||||
if (tokens > total) throw new Error('Kullanım toplamı ayrıntılarla uyuşmuyor.')
|
||||
const remainder = total - tokens
|
||||
return { low: low + remainder * minimum, high: high + remainder * maximum,
|
||||
uncertain: uncertain || remainder > 0 }
|
||||
}
|
||||
|
||||
export function estimateUsage(metadata, model) {
|
||||
if (model !== VOICE_PRICING.model) throw new Error('Bu ses modelinin fiyatı doğrulanmadı.')
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) ||
|
||||
!['promptTokenCount', 'responseTokenCount', 'totalTokenCount'].some(k => k in metadata))
|
||||
throw new Error('Google kullanım bilgisi eksik.')
|
||||
const prompt = count(metadata.promptTokenCount), response = count(metadata.responseTokenCount)
|
||||
const thoughts = count(metadata.thoughtsTokenCount), tools = count(metadata.toolUsePromptTokenCount)
|
||||
const total = count(metadata.totalTokenCount)
|
||||
if (metadata.totalTokenCount !== undefined && total < prompt + response)
|
||||
throw new Error('Kullanım toplamı sayaçlarla uyuşmuyor.')
|
||||
const input = modalityCost(prompt, metadata.promptTokensDetails, VOICE_PRICING.input)
|
||||
const output = modalityCost(response, metadata.responseTokensDetails, VOICE_PRICING.output)
|
||||
const tool = modalityCost(tools, metadata.toolUsePromptTokensDetails, VOICE_PRICING.input)
|
||||
// Thinking/tool counters may overlap aggregate counts. Keep a range instead
|
||||
// of promising a falsely precise bill or silently treating them as free.
|
||||
const residual = Math.max(0, total - prompt - response)
|
||||
const extraUpper = Math.max(residual * 12, thoughts * 4.5 + tool.high)
|
||||
return { lowUsd: (input.low + output.low) / 1e6,
|
||||
highUsd: (input.high + output.high + extraUpper) / 1e6,
|
||||
uncertain: input.uncertain || output.uncertain || extraUpper > 0 || count(metadata.cachedContentTokenCount) > 0 }
|
||||
}
|
||||
|
||||
export class VoiceUsage {
|
||||
constructor(model, limits = DEFAULT_VOICE_LIMITS) {
|
||||
this.model = model; this.limits = voiceLimits(limits)
|
||||
this.lowUsd = 0; this.highUsd = 0; this.reports = 0; this.uncertain = false
|
||||
}
|
||||
add(metadata) {
|
||||
// Do not de-duplicate equal reports: distinct requests can have equal
|
||||
// token counts, and subtracting prior prompt counts loses history cost.
|
||||
const cost = estimateUsage(metadata, this.model)
|
||||
this.lowUsd += cost.lowUsd; this.highUsd += cost.highUsd
|
||||
this.uncertain ||= cost.uncertain; this.reports++
|
||||
return this.snapshot()
|
||||
}
|
||||
snapshot() {
|
||||
return { lowUsd: this.lowUsd, highUsd: this.highUsd, reports: this.reports,
|
||||
uncertain: this.uncertain, reached: this.highUsd >= this.limits.usd }
|
||||
}
|
||||
}
|
||||
|
||||
export function usageLabel(usage, seconds) {
|
||||
const cost = !usage.reports ? 'Maliyet henüz bilinmiyor; Google kullanım bildirimi bekleniyor.' :
|
||||
`Bildirilen kullanımın ücretli tarife tahmini: ${usage.lowUsd.toFixed(4)}–${usage.highUsd.toFixed(4)} USD${usage.uncertain ? ' (eksik ayrıntılar için aralık)' : ''}.`
|
||||
return `Yerel süre: ${seconds} sn. ${cost} Gerçek fatura veya kalan ücretsiz kota değildir; IDE modeli dahil değildir.`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ASSISTANT_INSTRUCTION, VOICE_TOOLS, VoiceDispatchGate, taskText } from './behavior.mjs'
|
||||
import { VoiceUsage, VOICE_PRICING } from './voice-usage.mjs'
|
||||
|
||||
export function pcm16(samples, sampleRate) {
|
||||
const length = Math.floor(samples.length * 16000 / sampleRate)
|
||||
@@ -12,16 +13,23 @@ export function pcm16(samples, sampleRate) {
|
||||
}
|
||||
|
||||
export class VoiceSession {
|
||||
constructor({ token, model, onText, onDraft, onSubmit, onError, onState }) {
|
||||
Object.assign(this, { token, model, onText, onDraft, onSubmit, onError, onState })
|
||||
constructor({ token, model, onText, onDraft, onSubmit, onError, onState, onUsage, limits, now = () => performance.now() }) {
|
||||
Object.assign(this, { token, model, onText, onDraft, onSubmit, onError, onState, onUsage, now })
|
||||
this.usage = new VoiceUsage(model, limits)
|
||||
this.sources = new Set(); this.stopped = false; this.nextPlayback = 0
|
||||
this.dispatchGate = new VoiceDispatchGate(); this.receiveChain = Promise.resolve()
|
||||
this.audioTranscript = ''; this.preparedDraft = null
|
||||
}
|
||||
async start(microphone = true) {
|
||||
if (this.stopped) return
|
||||
if (this.model !== VOICE_PRICING.model) throw new Error('Bu ses modelinin maliyet tarifesi doğrulanmadı; bağlantı açılmadı.')
|
||||
this.startedAt = this.now(); this.lastUsageAt = this.startedAt
|
||||
this.usageTimer = setInterval(() => this.checkLimits(), 1000)
|
||||
this.checkLimits()
|
||||
try {
|
||||
this.context = new AudioContext()
|
||||
await this.context.resume()
|
||||
if (this.stopped) return
|
||||
if (microphone) {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } })
|
||||
if (this.stopped) { this.stream.getTracks().forEach(t => t.stop()); return }
|
||||
@@ -45,11 +53,21 @@ export class VoiceSession {
|
||||
this.socket.onmessage = event => { this.receiveChain = this.receiveChain.then(() => this.receive(event)).catch(() => { this.onError('Ses yanıtı işlenemedi.'); this.stop() }) }
|
||||
this.socket.onerror = () => { this.onError('Google ses bağlantısı kurulamadı. Anahtar, model erişimi ve kotayı kontrol edin.'); this.stop() }
|
||||
this.socket.onclose = () => this.stop()
|
||||
this.limit = setTimeout(() => { this.onText('bilgi', 'Bu görüşmenin 10 dakikalık yerel süre sınırı doldu. Bu bir fatura üst sınırı değildir.'); this.stop() }, 10 * 60000)
|
||||
this.setupTimer = setTimeout(() => { if (!this.ready) { this.onError('Google ses başlangıcı zaman aşımına uğradı.'); this.stop() } }, 20000)
|
||||
} catch { this.onError('Mikrofon veya ses bağlantısı açılamadı. Android mikrofon iznini kontrol edin; yazıyla devam edebilirsiniz.'); this.stop() }
|
||||
}
|
||||
send(frame) { if (this.socket?.readyState === 1) this.socket.send(JSON.stringify(frame)) }
|
||||
checkLimits() {
|
||||
if (this.stopped) return
|
||||
const seconds = Math.floor((this.now() - this.startedAt) / 1000)
|
||||
const usage = this.usage.snapshot()
|
||||
this.onUsage?.({ ...usage, seconds })
|
||||
let reason
|
||||
if (usage.reached) reason = 'Belirlediğiniz tahmini maliyet sınırına ulaşıldı.'
|
||||
else if (seconds >= this.usage.limits.minutes * 60) reason = 'Belirlediğiniz yerel görüşme süresi doldu.'
|
||||
else if (this.now() - this.lastUsageAt >= 120000) reason = 'İki dakikadır Google kullanım bildirimi alınamadı; maliyet izlenemediği için görüşme kapatıldı.'
|
||||
if (reason) { this.onText?.('bilgi', `${reason} Bu kontrol kesin fatura üst sınırı değildir.`); this.stop(reason) }
|
||||
}
|
||||
send(frame) { if (!this.stopped && this.socket?.readyState === 1) this.socket.send(JSON.stringify(frame)) }
|
||||
text(text) {
|
||||
if (!this.ready) throw new Error('Ses asistanı bağlantısı henüz hazır değil.')
|
||||
this.interruptPlayback()
|
||||
@@ -69,6 +87,11 @@ export class VoiceSession {
|
||||
async receive(event) {
|
||||
const data = JSON.parse(event.data instanceof Blob ? await event.data.text() : event.data)
|
||||
if (this.stopped) return
|
||||
if (data.usageMetadata) {
|
||||
try { this.usage.add(data.usageMetadata); this.lastUsageAt = this.now(); this.checkLimits() }
|
||||
catch { this.onError?.('Google kullanım bilgisi hesaplanamadı; maliyet takibi güvenilir olmadığı için ses görüşmesi kapatıldı.'); this.stop(); return }
|
||||
if (this.stopped) return
|
||||
}
|
||||
if (data.setupComplete) { this.ready = true; clearTimeout(this.setupTimer); this.onState('Google asistanı bağlı.'); return }
|
||||
if (data.error) { this.onError('Google ses isteğini reddetti. AI Studio erişim ve kotanızı kontrol edin.'); this.stop(); return }
|
||||
const content = data.serverContent
|
||||
@@ -116,12 +139,12 @@ export class VoiceSession {
|
||||
source.start(start); this.nextPlayback = start + buffer.duration
|
||||
}
|
||||
interruptPlayback() { for (const source of this.sources) { try { source.stop() } catch {} }; this.sources.clear(); this.nextPlayback = 0 }
|
||||
stop() {
|
||||
stop(reason = '') {
|
||||
if (this.stopped) return
|
||||
this.stopped = true; this.ready = false; clearTimeout(this.limit); clearTimeout(this.setupTimer)
|
||||
this.stopped = true; this.ready = false; clearInterval(this.usageTimer); clearTimeout(this.setupTimer)
|
||||
this.dispatchGate.invalidate()
|
||||
this.interruptPlayback(); this.stream?.getTracks().forEach(t => t.stop())
|
||||
this.input?.disconnect(); this.capture?.disconnect(); this.silence?.disconnect()
|
||||
this.socket?.close(); void this.context?.close(); this.onState('Ses görüşmesi kapalı. IDE işi devam edebilir.')
|
||||
this.socket?.close(); void this.context?.close(); this.onState?.(`${reason ? reason + ' ' : ''}Ses görüşmesi kapalı. IDE işi devam edebilir.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import qrcode from 'qrcode-generator'
|
||||
import jsQR from 'jsqr'
|
||||
import { parseInvite } from '../src/invite.mjs'
|
||||
const invite = { version: 2, desktopFingerprint: Array(32).fill('AB').join(':'), kind: 'aiturk-mobile-invite', inviteId: '11111111-1111-4111-8111-111111111111',
|
||||
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(600000).toISOString() }
|
||||
|
||||
test('invitation parser refuses URLs, expired and unknown invitations', () => {
|
||||
assert.equal(parseInvite(JSON.stringify(invite), 0).desktopId, invite.desktopId)
|
||||
assert.equal(parseInvite(JSON.stringify(invite), 0).desktopFingerprint, invite.desktopFingerprint)
|
||||
for (const bad of ['https://evil.test', JSON.stringify({ ...invite, version: 1 }),
|
||||
JSON.stringify({ ...invite, desktopFingerprint: undefined }), JSON.stringify({ ...invite, secret: 'short' })])
|
||||
assert.throws(() => parseInvite(bad, 0))
|
||||
assert.throws(() => parseInvite(JSON.stringify(invite), 600000))
|
||||
})
|
||||
test('bundled QR encoder and decoder round trip the actual invitation pixels', () => {
|
||||
const qr = qrcode(0, 'M'); qr.addData(JSON.stringify(invite)); qr.make()
|
||||
const n = qr.getModuleCount(), size = (n + 8) * 5, pixels = new Uint8ClampedArray(size * size * 4).fill(255)
|
||||
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) {
|
||||
for (let sy = 0; sy < 5; sy++) for (let sx = 0; sx < 5; sx++) {
|
||||
const i = (((y + 4) * 5 + sy) * size + (x + 4) * 5 + sx) * 4
|
||||
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0
|
||||
}
|
||||
}
|
||||
assert.deepEqual(JSON.parse(jsQR(pixels, size, size).data), invite)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalizeFingerprint, assertRemoteIdentity, parsePhoneIdentity } from '../src/peer-identity.mjs'
|
||||
import { openPhoneConnection } from '../src/transport.mjs'
|
||||
|
||||
const trusted = Array(32).fill('AB').join(':')
|
||||
const other = Array(32).fill('CD').join(':')
|
||||
const line = pin => `a=fingerprint:sha-256 ${pin}`
|
||||
|
||||
test('phone identity document binds the complete fingerprint to this pairing and device', () => {
|
||||
const value = { kind: 'aiturk-phone-identity', version: 1, pairingId: 'pair',
|
||||
deviceId: '33333333-3333-4333-8333-333333333333', fingerprint: trusted }
|
||||
assert.deepEqual(parsePhoneIdentity(JSON.stringify(value), 'pair'), { deviceId: value.deviceId, fingerprint: trusted })
|
||||
for (const bad of [{ ...value, pairingId: 'other' }, { ...value, fingerprint: 'AB' },
|
||||
{ ...value, deviceId: '__proto__' }, { ...value, version: 2 }])
|
||||
assert.throws(() => parsePhoneIdentity(JSON.stringify(bad), 'pair'))
|
||||
})
|
||||
|
||||
test('phone refuses absent or malformed saved trust before signaling or opening WebRTC', async () => {
|
||||
let calls = 0
|
||||
for (const desktopFingerprints of [undefined, {}, { desktop: 'invalid' }, Object.create({ desktop: trusted })]) {
|
||||
const account = { credentials: { desktopFingerprints }, call() { calls++; throw new Error('must not contact gateway') } }
|
||||
await assert.rejects(openPhoneConnection(account, 'desktop', () => {}), /kimliği/)
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
test('a peer identity is a complete SHA-256 fingerprint, not a name or partial digest', () => {
|
||||
assert.equal(normalizeFingerprint(trusted.toLowerCase()), trusted)
|
||||
for (const bad of [null, '', 'desktop', 'AB', trusted + ':AB', ' ' + trusted, trusted.replace('AB', 'ZZ')])
|
||||
assert.throws(() => normalizeFingerprint(bad))
|
||||
})
|
||||
|
||||
test('every advertised DTLS fingerprint must match the independently trusted peer', () => {
|
||||
assert.doesNotThrow(() => assertRemoteIdentity(`v=0\r\n${line(trusted)}\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n${line(trusted.toLowerCase())}\r\n`, trusted))
|
||||
for (const bad of ['', 'v=0', line(other), `${line(trusted)}\n${line(other)}`,
|
||||
line(trusted).replace('sha-256', 'sha-1'), line(trusted) + ' extra', 'x'.repeat(131073)])
|
||||
assert.throws(() => assertRemoteIdentity(bad, trusted))
|
||||
assert.throws(() => assertRemoteIdentity(line(trusted), undefined))
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { VoiceSession } from '../src/voice.mjs'
|
||||
import { VOICE_PRICING } from '../src/voice-usage.mjs'
|
||||
|
||||
test('stopping during audio resume never requests microphone access afterward', async t => {
|
||||
let resume, microphoneRequests = 0, closed = 0
|
||||
const originalContext = globalThis.AudioContext
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
globalThis.AudioContext = class {
|
||||
resume() { return new Promise(resolve => { resume = resolve }) }
|
||||
async close() { closed++ }
|
||||
}
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
|
||||
mediaDevices: { async getUserMedia() {
|
||||
microphoneRequests++
|
||||
return { getTracks: () => [] }
|
||||
} },
|
||||
} })
|
||||
t.after(() => {
|
||||
globalThis.AudioContext = originalContext
|
||||
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator)
|
||||
else delete globalThis.navigator
|
||||
})
|
||||
const voice = new VoiceSession({ model: VOICE_PRICING.model, onError() {} })
|
||||
t.after(() => voice.stop())
|
||||
const starting = voice.start()
|
||||
voice.stop()
|
||||
resume()
|
||||
await starting
|
||||
assert.equal(microphoneRequests, 0)
|
||||
assert.equal(closed, 1)
|
||||
assert.equal(voice.socket, undefined)
|
||||
})
|
||||
|
||||
test('a stopped session cannot reopen audio resources', async t => {
|
||||
let created = 0
|
||||
const originalContext = globalThis.AudioContext
|
||||
globalThis.AudioContext = class {
|
||||
constructor() { created++ }
|
||||
async resume() {}
|
||||
async close() {}
|
||||
}
|
||||
t.after(() => { globalThis.AudioContext = originalContext })
|
||||
const voice = new VoiceSession({ model: VOICE_PRICING.model, onError() {} })
|
||||
voice.stop()
|
||||
await voice.start(false)
|
||||
// Clean up an incorrectly restarted interval even when this assertion fails.
|
||||
clearInterval(voice.usageTimer)
|
||||
assert.equal(created, 0)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { VOICE_PRICING, VoiceUsage, estimateUsage, voiceLimits, usageLabel } from '../src/voice-usage.mjs'
|
||||
import { VoiceSession } from '../src/voice.mjs'
|
||||
|
||||
const model = VOICE_PRICING.model
|
||||
const report = { promptTokenCount: 2000, responseTokenCount: 1000, totalTokenCount: 3000,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 500 }, { modality: 'AUDIO', tokenCount: 1500 }],
|
||||
responseTokensDetails: [{ modality: 'TEXT', tokenCount: 100 }, { modality: 'AUDIO', tokenCount: 900 }] }
|
||||
const expected = (500 * VOICE_PRICING.input.TEXT + 1500 * VOICE_PRICING.input.AUDIO +
|
||||
100 * VOICE_PRICING.output.TEXT + 900 * VOICE_PRICING.output.AUDIO) / 1e6
|
||||
|
||||
test('reported input history is billed again on each request, including identical reports', () => {
|
||||
const usage = new VoiceUsage(model, { minutes: 10, usd: expected * 1.5 })
|
||||
assert.equal(usage.add(report).reached, false)
|
||||
const next = usage.add(report)
|
||||
assert.equal(next.lowUsd, expected * 2); assert.equal(next.highUsd, expected * 2)
|
||||
assert.equal(next.reports, 2); assert.equal(next.reached, true)
|
||||
})
|
||||
|
||||
test('missing or unknown modality yields a finite range, not zero/free usage', () => {
|
||||
for (const details of [undefined, [{ modality: 'toString', tokenCount: 3000 }]]) {
|
||||
const cost = estimateUsage({ promptTokenCount: 3000, promptTokensDetails: details }, model)
|
||||
assert.ok(cost.lowUsd > 0); assert.ok(cost.highUsd > cost.lowUsd); assert.equal(cost.uncertain, true)
|
||||
}
|
||||
const extra = estimateUsage({ ...report, thoughtsTokenCount: 100, toolUsePromptTokenCount: 100 }, model)
|
||||
assert.ok(extra.highUsd > expected); assert.equal(extra.uncertain, true)
|
||||
const noBreakdown = estimateUsage({ totalTokenCount: 1000 }, model)
|
||||
assert.ok(noBreakdown.highUsd > 0); assert.equal(noBreakdown.uncertain, true)
|
||||
})
|
||||
|
||||
test('invalid metadata cannot poison the running total or silently bypass the limit', () => {
|
||||
const usage = new VoiceUsage(model)
|
||||
usage.add(report); const before = usage.snapshot()
|
||||
for (const metadata of [{}, { promptTokenCount: -1 }, { responseTokenCount: NaN },
|
||||
{ promptTokenCount: '100' }, { ...report, totalTokenCount: 1 },
|
||||
{ promptTokenCount: 1, promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 2 }] }]) {
|
||||
assert.throws(() => usage.add(metadata)); assert.deepEqual(usage.snapshot(), before)
|
||||
}
|
||||
assert.throws(() => estimateUsage(report, 'unknown-model'))
|
||||
})
|
||||
|
||||
test('limits validate actual numeric bounds and usage label does not claim quota or invoice', () => {
|
||||
assert.deepEqual(voiceLimits({ minutes: '3', usd: '0.05' }), { minutes: 3, usd: 0.05 })
|
||||
for (const value of [{ minutes: 0, usd: 1 }, { minutes: 11, usd: 1 }, { minutes: 1, usd: 0 },
|
||||
{ minutes: 1, usd: Infinity }, { minutes: 1.5, usd: 0.25 }, { minutes: 1, usd: 11 }]) assert.throws(() => voiceLimits(value))
|
||||
assert.match(usageLabel(new VoiceUsage(model).snapshot(), 0), /henüz bilinmiyor/)
|
||||
const usage = new VoiceUsage(model); usage.add(report)
|
||||
assert.match(usageLabel(usage.snapshot(), 30), /IDE modeli dahil değildir/)
|
||||
})
|
||||
|
||||
test('usage threshold closes voice before a co-delivered tool can submit IDE work', async () => {
|
||||
let dispatched = 0, closed = 0, tracks = 0
|
||||
const notices = []
|
||||
const voice = new VoiceSession({ model, limits: { minutes: 10, usd: 0.01 }, onText: (_, text) => notices.push(text),
|
||||
onState: text => notices.push(text), onSubmit: () => { dispatched++ } })
|
||||
voice.startedAt = voice.now(); voice.lastUsageAt = voice.startedAt
|
||||
voice.stream = { getTracks: () => [{ stop() { tracks++ } }] }
|
||||
voice.socket = { readyState: 1, close() { closed++ }, send() { throw new Error('must not send after cap') } }
|
||||
await voice.receive({ data: JSON.stringify({ usageMetadata: report,
|
||||
toolCall: { functionCalls: [{ id: '1', name: 'submit_task', args: {} }] } }) })
|
||||
voice.text = VoiceSession.prototype.text.bind(voice)
|
||||
assert.throws(() => voice.text('Bunu yap'))
|
||||
voice.result({ status: 'complete', text: 'Done' }); voice.stop()
|
||||
assert.equal(voice.stopped, true); assert.deepEqual([dispatched, closed, tracks], [0, 1, 1])
|
||||
assert.ok(notices.some(text => text.includes('maliyet sınırına')))
|
||||
assert.ok(notices.some(text => text.includes('IDE işi devam edebilir')))
|
||||
})
|
||||
|
||||
test('malformed usage closes an established session with a visible error', async () => {
|
||||
const errors = []
|
||||
const voice = new VoiceSession({ model, onError: text => errors.push(text) })
|
||||
voice.ready = true
|
||||
await voice.receive({ data: JSON.stringify({ usageMetadata: {} }) })
|
||||
assert.equal(voice.stopped, true); assert.match(errors[0], /hesaplanamadı/)
|
||||
})
|
||||
|
||||
for (const scenario of [
|
||||
{ minutes: 1, elapsed: 60000, reason: /süresi doldu/ },
|
||||
{ minutes: 10, elapsed: 120000, reason: /bildirimi alınamadı/ },
|
||||
]) test(`real voice lifecycle timer stops without resuming: ${scenario.minutes} minute setting`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
|
||||
let now = 0, connections = 0, closed = 0
|
||||
const messages = [], usage = []
|
||||
const originalContext = globalThis.AudioContext, originalSocket = globalThis.WebSocket
|
||||
globalThis.AudioContext = class { async resume() {} async close() {} }
|
||||
globalThis.WebSocket = class { constructor() { connections++ } close() { closed++ } }
|
||||
t.after(() => { globalThis.AudioContext = originalContext; globalThis.WebSocket = originalSocket })
|
||||
const voice = new VoiceSession({ token: 'test-only', model, now: () => now,
|
||||
limits: { minutes: scenario.minutes, usd: 0.25 }, onText() {}, onError: text => messages.push(text),
|
||||
onState: text => messages.push(text), onUsage: value => usage.push(value) })
|
||||
t.after(() => voice.stop())
|
||||
await voice.start(false)
|
||||
await voice.receive({ data: JSON.stringify({ setupComplete: {} }) })
|
||||
now = scenario.elapsed; t.mock.timers.tick(scenario.elapsed)
|
||||
assert.equal(voice.stopped, true); assert.deepEqual([connections, closed], [1, 1])
|
||||
assert.ok(messages.some(text => scenario.reason.test(text)))
|
||||
assert.equal(usage.at(-1).seconds, scenario.elapsed / 1000)
|
||||
t.mock.timers.tick(600000); assert.equal(connections, 1)
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Connect the AITURK distribution's media tools to its existing member key."""
|
||||
|
||||
|
||||
def configure_existing_media(cfg: dict) -> bool:
|
||||
"""Migrate an existing member endpoint when the upgraded backend starts."""
|
||||
providers = cfg.get("providers") or {}
|
||||
entries = providers.values() if isinstance(providers, dict) else []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and configure_media(cfg, entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def configure_media(cfg: dict, entry: dict) -> bool:
|
||||
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
|
||||
return False
|
||||
key_env = entry.get("key_env")
|
||||
if not isinstance(key_env, str) or not key_env:
|
||||
return False
|
||||
servers = cfg.get("mcp_servers")
|
||||
if servers is None:
|
||||
servers = cfg["mcp_servers"] = {}
|
||||
if not isinstance(servers, dict):
|
||||
return False
|
||||
name = "turkservis-media"
|
||||
existing = servers.get(name, {})
|
||||
if not isinstance(existing, dict):
|
||||
return False
|
||||
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
|
||||
return False
|
||||
updated = {
|
||||
**existing,
|
||||
"url": "https://ai.turkservis.online/mcp",
|
||||
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
|
||||
"timeout": 300,
|
||||
"tools": {"include": ["list_capabilities", "generate_media"]},
|
||||
}
|
||||
if updated == existing:
|
||||
return False
|
||||
servers[name] = updated
|
||||
return True
|
||||
+21
-1
@@ -1474,7 +1474,27 @@ def run_doctor(args):
|
||||
check_ok(name, "(optional)")
|
||||
except ImportError:
|
||||
check_warn(name, "(optional, not installed)")
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Voice wheels can be installed yet unloadable when the Microsoft VC++
|
||||
# runtime is missing; package presence alone hides that failure.
|
||||
from tools.windows_native_runtime import native_load_error
|
||||
|
||||
for module, name in (
|
||||
("onnxruntime", "ONNX Runtime (wake word)"),
|
||||
("ctranslate2", "CTranslate2 (local speech-to-text)"),
|
||||
):
|
||||
try:
|
||||
installed = importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError):
|
||||
installed = False
|
||||
if not installed:
|
||||
continue
|
||||
if native_load_error(module) is None:
|
||||
check_ok(name, "(native runtime loads)")
|
||||
else:
|
||||
check_warn(name, "(Microsoft VC++ runtime missing - restart AITURK IDE to restore it)")
|
||||
|
||||
_section("Configuration Files")
|
||||
# Managed scope (administrator-pinned config/env), when present.
|
||||
managed_scope_check()
|
||||
|
||||
@@ -405,12 +405,6 @@ def _eager_reconcile_own_session_db() -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
from hermes_cli.aiturk_media import configure_existing_media
|
||||
from hermes_cli.config import read_raw_config, save_config
|
||||
with _CONFIG_MUTATION_LOCK:
|
||||
media_cfg = read_raw_config()
|
||||
if configure_existing_media(media_cfg):
|
||||
save_config(media_cfg)
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
app.state.pty_active_session_files = {} # dict[str, Path]
|
||||
@@ -8829,9 +8823,6 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
|
||||
providers[endpoint_id] = entry
|
||||
cfg["providers"] = providers
|
||||
|
||||
from hermes_cli.aiturk_media import configure_media
|
||||
configure_media(cfg, entry)
|
||||
|
||||
if body.make_default:
|
||||
cfg["model"] = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), endpoint_id, model, base_url
|
||||
|
||||
Generated
+44
-3
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.10",
|
||||
"version": "1.0.0-beta.13",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
@@ -544,10 +544,12 @@
|
||||
},
|
||||
"apps/mobile": {
|
||||
"name": "aiturk-mobile",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.5.1",
|
||||
"@capacitor/core": "8.5.1"
|
||||
"@capacitor/core": "8.5.1",
|
||||
"jsqr": "1.4.0",
|
||||
"qrcode-generator": "2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "8.5.1"
|
||||
@@ -6854,6 +6856,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
|
||||
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
|
||||
"optional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {"@tybys/wasm-util": "^0.10.3"},
|
||||
"engines": {"node": "^20.19.0 || ^22.13.0 || >=23.5.0"}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"optional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {"tslib": "^2.4.0"}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
@@ -13599,6 +13618,12 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
@@ -16525,6 +16550,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -19480,6 +19511,16 @@
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": ["x64"],
|
||||
"os": ["win32"],
|
||||
"optional": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {"node": ">= 12.0.0"}
|
||||
},
|
||||
"node_modules/vite/node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: aiturk-media
|
||||
description: "Generate images, video and speech or transcribe audio with the user's TurkServis account through the AITURK CLI (`aiturk media`). Use whenever the user asks for an image, video, voice-over or transcript."
|
||||
version: 1.0.0
|
||||
author: AITURK / TurkServis
|
||||
license: MIT
|
||||
platforms: [windows]
|
||||
prerequisites:
|
||||
commands: [aiturk]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Media, Image, Video, TTS, STT, AITURK, TurkServis]
|
||||
---
|
||||
|
||||
# AITURK media (image, video, speech, transcription)
|
||||
|
||||
AITURK IDE produces media with the AITURK CLI that is installed next to it. The
|
||||
CLI uses the same TurkServis account; usage is billed to the member's balance.
|
||||
Do not add or enable an MCP media server for this.
|
||||
|
||||
## When to use
|
||||
|
||||
The user asks for an image, a short video, spoken audio from text, or a
|
||||
transcript of an audio file.
|
||||
|
||||
## Steps
|
||||
|
||||
The launcher is `aiturk.cmd`. In PowerShell or cmd type `aiturk`; in a bash-style
|
||||
terminal (Git Bash) type `aiturk.cmd` instead, with the same arguments.
|
||||
|
||||
1. List what the account can use right now. Never rely on a remembered model list.
|
||||
|
||||
```shell
|
||||
aiturk media list --json
|
||||
```
|
||||
|
||||
Use a capability only when its `status` is `available`. `unknown` is an
|
||||
access error; `unavailable` means no usable model. Pick the model the user
|
||||
named, or a fitting `id` from the list. Never invent a model id.
|
||||
|
||||
2. Run one generation. Choose an output file name that does not exist yet.
|
||||
|
||||
```shell
|
||||
aiturk media run image --model <model-id> --text "<what the user asked for>" --output image.png
|
||||
aiturk media run video --model <model-id> --text "<prompt>" --seconds 5 --output clip.mp4
|
||||
aiturk media run tts --model <model-id> --text "<text>" --voice <voice> --output speech.mp3
|
||||
aiturk media run stt --model <model-id> --audio <audio-file> --output transcript.txt
|
||||
```
|
||||
|
||||
3. Confirm the output file exists and has the expected type, then show or link
|
||||
it to the user. Do not retry a failed or interrupted generation automatically;
|
||||
each run is billed.
|
||||
|
||||
## If the command is unavailable
|
||||
|
||||
- `aiturk` and `aiturk.cmd` both not found: try `%LOCALAPPDATA%\TurkServis\AITURK\program\bin\aiturk.cmd`.
|
||||
If that is missing too, tell the user to install the AITURK CLI from
|
||||
https://turkservis.online/indir.
|
||||
- Not signed in or an authentication error: ask the user to run `aiturk login`
|
||||
once, then `aiturk doctor`.
|
||||
- Only while the CLI cannot be used at all may an already configured TurkServis
|
||||
media connection be used instead; say that you did and why.
|
||||
|
||||
Never print API keys, tokens or authorization headers. Without a generation
|
||||
request, list only; do not start a paid trial generation.
|
||||
@@ -625,6 +625,19 @@ def _neutralize_webbrowser(monkeypatch):
|
||||
return opened
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _neutralize_native_runtime_probe(monkeypatch):
|
||||
"""Report native voice wheels as loadable unless a test says otherwise.
|
||||
|
||||
The real probe imports onnxruntime/ctranslate2 in a child interpreter, so
|
||||
its answer depends on the host's VC++ runtime rather than the code under
|
||||
test. tests/tools/test_windows_native_runtime.py exercises the real probe.
|
||||
"""
|
||||
import tools.windows_native_runtime as _native_runtime
|
||||
|
||||
monkeypatch.setattr(_native_runtime, "native_load_error", lambda module: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _neutralize_macos_keychain_creds(request, monkeypatch):
|
||||
"""Default Anthropic credential resolution away from the real macOS Keychain."""
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Windows VC++ runtime detection for the native voice wheels."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.transcription_tools as tt
|
||||
import tools.wake_word as ww
|
||||
from tools import windows_native_runtime as wnr
|
||||
|
||||
_NATIVE_LOAD_ERROR = wnr.native_load_error
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_probe_cache():
|
||||
_NATIVE_LOAD_ERROR.cache_clear()
|
||||
yield
|
||||
_NATIVE_LOAD_ERROR.cache_clear()
|
||||
|
||||
|
||||
def _fake_run(returncode, stderr, calls=None):
|
||||
def run(cmd, **kwargs):
|
||||
if calls is not None:
|
||||
calls.append((cmd, kwargs))
|
||||
return subprocess.CompletedProcess(cmd, returncode, stdout="", stderr=stderr)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def test_non_windows_never_probes(monkeypatch):
|
||||
monkeypatch.setattr(wnr.sys, "platform", "linux")
|
||||
monkeypatch.setattr(wnr.subprocess, "run", pytest.fail)
|
||||
|
||||
assert _NATIVE_LOAD_ERROR("onnxruntime") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stderr",
|
||||
[
|
||||
"ImportError: DLL load failed while importing onnxruntime_pybind11_state: The specified module could not be found.",
|
||||
"ImportError: DLL load failed while importing onnxruntime_pybind11_state: Belirtilen modül bulunamadı.",
|
||||
"FileNotFoundError: Could not find module 'C:\\venv\\ctranslate2\\ctranslate2.dll' (or one of its dependencies).",
|
||||
],
|
||||
)
|
||||
def test_dll_load_failure_is_reported(monkeypatch, stderr):
|
||||
calls = []
|
||||
monkeypatch.setattr(wnr.sys, "platform", "win32")
|
||||
monkeypatch.setattr(wnr.subprocess, "run", _fake_run(1, "Traceback (most recent call last):\n" + stderr, calls))
|
||||
|
||||
assert _NATIVE_LOAD_ERROR("onnxruntime") == stderr
|
||||
assert calls[0][0][1:] == ["-c", "import onnxruntime"]
|
||||
assert calls[0][1]["timeout"] > 0
|
||||
|
||||
|
||||
def test_missing_package_is_not_a_runtime_failure(monkeypatch):
|
||||
monkeypatch.setattr(wnr.sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
wnr.subprocess, "run", _fake_run(1, "ModuleNotFoundError: No module named 'onnxruntime'")
|
||||
)
|
||||
|
||||
assert _NATIVE_LOAD_ERROR("onnxruntime") is None
|
||||
|
||||
|
||||
def test_probe_result_is_cached_per_process(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(wnr.sys, "platform", "win32")
|
||||
monkeypatch.setattr(wnr.subprocess, "run", _fake_run(0, "", calls))
|
||||
|
||||
assert _NATIVE_LOAD_ERROR("ctranslate2") is None
|
||||
assert _NATIVE_LOAD_ERROR("ctranslate2") is None
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_inconclusive_probe_does_not_block(monkeypatch):
|
||||
def timeout(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
|
||||
|
||||
monkeypatch.setattr(wnr.sys, "platform", "win32")
|
||||
monkeypatch.setattr(wnr.subprocess, "run", timeout)
|
||||
|
||||
assert _NATIVE_LOAD_ERROR("onnxruntime") is None
|
||||
|
||||
|
||||
def test_module_name_is_validated(monkeypatch):
|
||||
monkeypatch.setattr(wnr.sys, "platform", "win32")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_NATIVE_LOAD_ERROR("os; import shutil")
|
||||
|
||||
|
||||
def test_wake_requirements_report_missing_vc_runtime(monkeypatch):
|
||||
monkeypatch.setattr(ww, "_stt_ready", lambda: True)
|
||||
monkeypatch.setattr(ww, "_tts_ready", lambda: True)
|
||||
monkeypatch.setattr(ww, "_audio_available", lambda: True)
|
||||
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: True)
|
||||
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
|
||||
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(ww, "_native_runtime_error", lambda module: "ImportError: DLL load failed")
|
||||
|
||||
reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"})
|
||||
|
||||
assert reqs["available"] is False
|
||||
assert reqs["native_runtime_error"] == "ImportError: DLL load failed"
|
||||
assert "msvcp140.dll" in reqs["hint"]
|
||||
|
||||
|
||||
def test_wake_requirements_unaffected_when_runtime_loads(monkeypatch):
|
||||
monkeypatch.setattr(ww, "_stt_ready", lambda: True)
|
||||
monkeypatch.setattr(ww, "_tts_ready", lambda: True)
|
||||
monkeypatch.setattr(ww, "_audio_available", lambda: True)
|
||||
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: True)
|
||||
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
|
||||
monkeypatch.setattr(ww, "_native_runtime_error", lambda module: None)
|
||||
|
||||
reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"})
|
||||
|
||||
assert reqs["available"] is True
|
||||
assert reqs["native_runtime_error"] is None
|
||||
|
||||
|
||||
def test_local_transcription_reports_missing_vc_runtime(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(tt, "_HAS_FASTER_WHISPER", True)
|
||||
monkeypatch.setattr(wnr, "native_load_error", lambda module: "ImportError: DLL load failed")
|
||||
monkeypatch.setattr(tt, "_load_stt_config", pytest.fail)
|
||||
|
||||
result = tt._transcribe_local(str(tmp_path / "a.wav"), "base")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "msvcp140.dll" in result["error"]
|
||||
@@ -7,6 +7,7 @@ import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import psutil
|
||||
|
||||
from tui_gateway.compute_host import ComputeHost, HostSession
|
||||
|
||||
@@ -49,7 +50,11 @@ def test_compute_host_line_json_seed_turn_interrupt():
|
||||
try:
|
||||
hello = _read_json_line(out)
|
||||
assert hello["type"] == "hello"
|
||||
assert hello["host_pid"] == proc.pid
|
||||
# Native Windows venv python.exe is a redirector: Popen owns the shim
|
||||
# while the actual Python child reports its own PID. Verify the real
|
||||
# process ancestry instead of mistaking that topology for a host bug.
|
||||
reported = psutil.Process(hello["host_pid"])
|
||||
assert proc.pid in {reported.pid, *(parent.pid for parent in reported.parents())}
|
||||
|
||||
proc.stdin.write(json.dumps({"type": "session.seed", "sid": "s1", "request_id": "seed"}) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -14,6 +15,7 @@ from tui_gateway.host_supervisor import (
|
||||
MUTATOR_ROUTE_TABLE,
|
||||
HostSupervisor,
|
||||
append_log_record,
|
||||
_pid_alive,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,6 +120,35 @@ def test_append_log_record_single_write_lines(tmp_path):
|
||||
assert all(line.endswith("x" * 2000) for line in lines)
|
||||
|
||||
|
||||
def test_append_log_record_across_processes_preserves_unicode(tmp_path):
|
||||
path = tmp_path / "nested" / "shared.log"
|
||||
child_code = """
|
||||
import sys, time
|
||||
from tui_gateway.host_supervisor import append_log_record
|
||||
for i in range(100):
|
||||
append_log_record(sys.argv[1], f'{sys.argv[2]}:{i}:' + '\u0130\u015f\u011f\U0001f680' * 100)
|
||||
time.sleep(0.001)
|
||||
"""
|
||||
children = [subprocess.Popen(
|
||||
[sys.executable, "-c", child_code, str(path), str(index)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
) for index in range(4)]
|
||||
try:
|
||||
for child in children:
|
||||
_, errors = child.communicate(timeout=30)
|
||||
assert child.returncode == 0, errors.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
for child in children:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.communicate(timeout=10)
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
expected = {f"{process}:{index}:" + "İşğ🚀" * 100 for process in range(4) for index in range(100)}
|
||||
assert len(lines) == len(expected)
|
||||
assert set(lines) == expected
|
||||
|
||||
|
||||
def test_supervisor_startup_reconcile_pid_reuse_guard(tmp_path, monkeypatch):
|
||||
registry = tmp_path / "dashboard-compute-host.json"
|
||||
registry.write_text(json.dumps({"host_pid": os.getpid(), "boot_id": "stale"}), encoding="utf-8")
|
||||
@@ -134,6 +165,26 @@ def test_supervisor_startup_reconcile_pid_reuse_guard(tmp_path, monkeypatch):
|
||||
assert not registry.exists()
|
||||
|
||||
|
||||
def test_pid_probe_does_not_terminate_a_live_process():
|
||||
# Use the base interpreter, not the Windows venv launcher/worker chain.
|
||||
child = subprocess.Popen(
|
||||
[getattr(sys, "_base_executable", sys.executable), "-c",
|
||||
"import sys; sys.stdin.readline(); print('still-alive', flush=True)"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
try:
|
||||
assert _pid_alive(child.pid)
|
||||
output, errors = child.communicate(input=b"ping\n", timeout=10)
|
||||
assert child.returncode == 0, errors.decode("utf-8", errors="replace")
|
||||
assert output.strip() == b"still-alive", "A liveness query terminated the process"
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
child.wait(timeout=10)
|
||||
assert not _pid_alive(child.pid)
|
||||
|
||||
|
||||
def _make_compress_host_session(events: list) -> dict:
|
||||
class _Agent:
|
||||
model = "host-model"
|
||||
|
||||
@@ -1,35 +1,52 @@
|
||||
from hermes_cli.aiturk_media import configure_media, configure_existing_media
|
||||
"""AITURK IDE produces media through the AITURK CLI, never by adding an MCP server."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILL = ROOT / "skills" / "media" / "aiturk-media" / "SKILL.md"
|
||||
|
||||
|
||||
def test_existing_member_profile_migrates_once_without_changing_provider():
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}
|
||||
cfg = {"providers": {"turkservis": dict(entry)}, "model": {"default": "kept"}}
|
||||
assert configure_existing_media(cfg)
|
||||
assert cfg["providers"]["turkservis"] == entry
|
||||
assert cfg["model"] == {"default": "kept"}
|
||||
assert not configure_existing_media(cfg)
|
||||
def test_backend_never_registers_a_turkservis_media_mcp_server():
|
||||
assert importlib.util.find_spec("hermes_cli.aiturk_media") is None
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
for needle in ("aiturk_media", "turkservis-media", "generate_media", "ai.turkservis.online/mcp"):
|
||||
assert needle not in source
|
||||
|
||||
|
||||
def test_member_key_is_referenced_without_copying_or_overwriting_other_servers():
|
||||
cfg = {"mcp_servers": {"other": {"url": "https://example.org/mcp"}}}
|
||||
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "HERMES_CUSTOM_TURKSERVIS_API_KEY"}
|
||||
assert configure_media(cfg, entry)
|
||||
assert cfg["mcp_servers"]["other"] == {"url": "https://example.org/mcp"}
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"] == "Bearer ${HERMES_CUSTOM_TURKSERVIS_API_KEY}"
|
||||
assert not configure_media(cfg, entry)
|
||||
entry["key_env"] = "ROTATED_MEMBER_KEY"
|
||||
assert configure_media(cfg, entry)
|
||||
assert "ROTATED_MEMBER_KEY" in cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"]
|
||||
def test_no_backend_module_writes_the_turkservis_mcp_endpoint_into_config():
|
||||
offenders = [
|
||||
path.relative_to(ROOT).as_posix()
|
||||
for package in ("hermes_cli", "agent", "tools", "gateway", "tui_gateway")
|
||||
for path in (ROOT / package).rglob("*.py")
|
||||
if "ai.turkservis.online/mcp" in path.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_other_provider_and_missing_key_do_not_enable_media():
|
||||
for entry in ({"base_url": "https://example.com/v1", "key_env": "SECRET"}, {"base_url": "https://ai.turkservis.online/v1"}):
|
||||
cfg = {}
|
||||
assert not configure_media(cfg, entry)
|
||||
assert cfg == {}
|
||||
def test_existing_user_mcp_entries_are_left_exactly_as_they_are():
|
||||
# Startup used to rewrite config.yaml; with the writer gone, a saved config
|
||||
# round-trips untouched (including a disabled turkservis-media entry).
|
||||
before = {
|
||||
"providers": {"turkservis": {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}},
|
||||
"mcp_servers": {
|
||||
"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False},
|
||||
"other": {"url": "https://example.org/mcp"},
|
||||
},
|
||||
}
|
||||
source = (ROOT / "hermes_cli" / "web_server.py").read_text(encoding="utf-8")
|
||||
lifespan = source[source.index("async def _lifespan("):]
|
||||
lifespan = lifespan[:lifespan.index("yield")]
|
||||
assert "save_config" not in lifespan and "mcp_servers" not in lifespan
|
||||
assert yaml.safe_load(yaml.safe_dump(before)) == before
|
||||
|
||||
|
||||
def test_explicitly_disabled_connection_stays_disabled():
|
||||
cfg = {"mcp_servers": {"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False}}}
|
||||
configure_media(cfg, {"base_url": "https://ai.turkservis.online/v1", "key_env": "KEY"})
|
||||
assert cfg["mcp_servers"]["turkservis-media"]["enabled"] is False
|
||||
def test_bundled_skill_teaches_aiturk_media():
|
||||
text = SKILL.read_text(encoding="utf-8")
|
||||
meta = yaml.safe_load(text.split("---")[1])
|
||||
assert meta["name"] == "aiturk-media"
|
||||
for phrase in ("aiturk media list --json", "aiturk media run image", "aiturk media run video",
|
||||
"aiturk media run tts", "aiturk media run stt", "aiturk login"):
|
||||
assert phrase in text
|
||||
assert "Do not add or enable an MCP media server" in text
|
||||
|
||||
@@ -1942,6 +1942,14 @@ def _transcribe_local(
|
||||
if not _try_lazy_install_stt():
|
||||
return {"success": False, "transcript": "", "error": "faster-whisper not installed"}
|
||||
|
||||
# faster-whisper imports lazily, so an unloadable ctranslate2 (missing
|
||||
# VC++ runtime on Windows) would otherwise surface as an opaque DLL error.
|
||||
from tools.windows_native_runtime import native_load_error, voice_runtime_error_message
|
||||
|
||||
native_error = native_load_error("ctranslate2")
|
||||
if native_error:
|
||||
return {"success": False, "transcript": "", "error": voice_runtime_error_message(native_error)}
|
||||
|
||||
try:
|
||||
local_cfg = _load_stt_config().get("local") or {}
|
||||
# Reset the idle timer BEFORE loading/transcribing so the idle-unload
|
||||
|
||||
+16
-1
@@ -896,6 +896,12 @@ def _tts_ready() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _native_runtime_error(module: str) -> Optional[str]:
|
||||
from tools.windows_native_runtime import native_load_error
|
||||
|
||||
return native_load_error(module)
|
||||
|
||||
|
||||
def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Report whether wake-word detection can run, with a remediation hint."""
|
||||
cfg = cfg if cfg is not None else load_wake_word_config()
|
||||
@@ -910,6 +916,10 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
feature = "wake.openwakeword"
|
||||
deps_ok = lazy_deps.is_available(feature)
|
||||
lazy_ok = lazy_deps._allow_lazy_installs()
|
||||
# Installed metadata does not prove the native wheel loads: on Windows
|
||||
# without the VC++ runtime onnxruntime is "installed" yet every import
|
||||
# fails. Report that instead of a lazy install that cannot help.
|
||||
native_error = _native_runtime_error("onnxruntime") if deps_ok and feature == "wake.openwakeword" else None
|
||||
# The audio probe imports sounddevice + numpy — two of the very packages
|
||||
# the lazy installer would fetch — so it can only be trusted once the
|
||||
# feature's deps are installed. On a fresh install (deps missing, lazy
|
||||
@@ -937,6 +947,10 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip():
|
||||
key_ok = False
|
||||
hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)."
|
||||
elif native_error:
|
||||
from tools.windows_native_runtime import voice_runtime_error_message
|
||||
|
||||
hint = voice_runtime_error_message(native_error)
|
||||
elif not deps_ok and not lazy_ok:
|
||||
hint = lazy_deps.feature_install_command(feature) or ""
|
||||
elif not tflite_ok:
|
||||
@@ -969,7 +983,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
)
|
||||
|
||||
return {
|
||||
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok,
|
||||
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok and native_error is None,
|
||||
"provider": provider,
|
||||
"deps_available": deps_ok,
|
||||
"audio_available": audio_ok,
|
||||
@@ -979,6 +993,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
"stt_available": stt_ok,
|
||||
"tts_available": tts_ok,
|
||||
"phrase": wake_phrase(cfg),
|
||||
"native_runtime_error": native_error,
|
||||
"hint": hint,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Detect native voice wheels that are installed but cannot load on Windows.
|
||||
|
||||
Package metadata (what ``tools.lazy_deps`` checks) reports onnxruntime and
|
||||
ctranslate2 as installed even when their DLLs cannot load because the
|
||||
Microsoft Visual C++ runtime (msvcp140.dll) is missing. A lazy reinstall cannot
|
||||
fix that, so callers need to tell the two cases apart. Importing the module in
|
||||
a child interpreter does so without risking the calling process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
_MODULE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
||||
|
||||
# Windows reports the same loader failure in the user's UI language.
|
||||
_DLL_LOAD_MARKERS = (
|
||||
"dll load failed",
|
||||
"could not find module",
|
||||
"specified module could not be found",
|
||||
"belirtilen modül bulunamadı",
|
||||
"[winerror 126]",
|
||||
)
|
||||
|
||||
VC_RUNTIME_HINT = (
|
||||
"Ses bileşeni yüklenemedi: Microsoft Visual C++ çalışma zamanı (msvcp140.dll) eksik. "
|
||||
"AITURK IDE'yi kapatıp yeniden açın; IDE eksik dosyaları otomatik olarak geri yükler."
|
||||
)
|
||||
|
||||
|
||||
def looks_like_dll_load_failure(message: str) -> bool:
|
||||
text = (message or "").lower()
|
||||
return any(marker in text for marker in _DLL_LOAD_MARKERS)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def native_load_error(module: str) -> Optional[str]:
|
||||
"""Return the DLL load error for *module* on Windows, otherwise None.
|
||||
|
||||
None also covers "not installed" and inconclusive probes: this helper only
|
||||
answers "installed but its native libraries cannot load". Cached per
|
||||
process because the runtime cannot change under a running backend (the
|
||||
desktop restores it before the backend starts).
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
if not _MODULE_RE.match(module):
|
||||
raise ValueError(f"invalid module name: {module!r}")
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", f"import {module}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
env=dict(os.environ, PYTHONIOENCODING="utf-8"),
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
stderr = proc.stderr or ""
|
||||
if proc.returncode == 0 or not looks_like_dll_load_failure(stderr):
|
||||
return None
|
||||
lines = [line.strip() for line in stderr.splitlines() if line.strip()]
|
||||
return lines[-1] if lines else f"import {module} failed"
|
||||
|
||||
|
||||
def voice_runtime_error_message(error: str) -> str:
|
||||
return f"{VC_RUNTIME_HINT} ({error})"
|
||||
@@ -22,6 +22,8 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
|
||||
@@ -55,14 +57,35 @@ _LATE_CONTROL_MAX = 64
|
||||
|
||||
|
||||
def append_log_record(path: str | Path, record: str) -> None:
|
||||
"""Append one log record using O_APPEND and exactly one os.write call."""
|
||||
"""Append a record with one native write and kernel-owned EOF placement."""
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = record if record.endswith("\n") else f"{record}\n"
|
||||
data = text.encode("utf-8", errors="replace")
|
||||
if sys.platform == "win32":
|
||||
import ntsecuritycon
|
||||
import win32con
|
||||
import win32file
|
||||
|
||||
# The Windows CRT's O_APPEND seeks before writing, so independent file
|
||||
# descriptors can choose the same offset. Append-only access delegates
|
||||
# EOF placement to the kernel, without granting overwrite permission.
|
||||
handle = win32file.CreateFile(
|
||||
str(p), ntsecuritycon.FILE_APPEND_DATA,
|
||||
win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE | win32con.FILE_SHARE_DELETE,
|
||||
None, win32con.OPEN_ALWAYS, win32con.FILE_ATTRIBUTE_NORMAL, None,
|
||||
)
|
||||
try:
|
||||
_, written = win32file.WriteFile(handle, data)
|
||||
if written != len(data):
|
||||
raise OSError("Incomplete log record write")
|
||||
finally:
|
||||
handle.Close()
|
||||
return
|
||||
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
if os.write(fd, data) != len(data):
|
||||
raise OSError("Incomplete log record write")
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
@@ -91,17 +114,9 @@ def _default_registry_path() -> Path:
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
# Signal zero is not a portable liveness query (CTRL_C_EVENT on Windows).
|
||||
# pid_exists also distinguishes exited Windows processes with open handles.
|
||||
return pid > 0 and psutil.pid_exists(pid)
|
||||
|
||||
|
||||
def _pid_command(pid: int) -> str:
|
||||
|
||||
Reference in New Issue
Block a user