Files
aiturk-hermes-ide/apps/mobile/src/transport.mjs
T

147 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
if (!plugin) return fetch(url, options)
const target = new URL(url)
if (target.origin !== GATEWAY || target.search || target.hash) throw new Error('Geçersiz hesap adresi.')
const result = await plugin.request({ path: target.pathname, method: options.method, headers: options.headers, body: options.body || '' })
return { ok: result.status >= 200 && result.status < 300, status: result.status, json: () => JSON.parse(result.body) }
}
export class AccountClient {
constructor(credentials, fetcher = accountFetch) { this.credentials = credentials; this.fetcher = fetcher }
async call(path, body, method = body === undefined ? 'GET' : 'POST') {
if (!path.startsWith('/v1/aiturk/')) throw new Error('Geçersiz hesap işlemi.')
const c = this.credentials
let response
try {
response = await this.fetcher(GATEWAY + path, { method,
headers: { 'Authorization': `Bearer ${c.accountToken}`, 'Content-Type': 'application/json',
...(c.deviceId ? { 'X-Aiturk-Device-ID': c.deviceId, 'X-Aiturk-Device-Token': c.deviceToken } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(20000), redirect: 'error', credentials: 'omit' })
} catch { throw new Error('Hesaba ulaşılamadı. İnternet bağlantısını kontrol edin.') }
if (!response.ok) throw new Error(response.status === 401 || response.status === 403
? 'Hesap veya cihaz yetkisi geçersiz. Eşleşmeyi kontrol edin.'
: `Bağlantı hizmeti isteği karşılayamadı (${response.status}).`)
return response.json()
}
}
export async function completeIce(pc, timeout = 15000) {
if (pc.iceGatheringState === 'complete') return
await new Promise((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error('Ağ adresleri alınamadı. Bağlantıyı yeniden deneyin.')) }, timeout)
const listener = () => { if (pc.iceGatheringState === 'complete') { cleanup(); resolve() } }
const cleanup = () => { clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', listener) }
pc.addEventListener('icegatheringstatechange', listener)
listener()
})
}
export class DataRequests {
constructor(channel, onEvent) {
this.channel = channel; this.onEvent = onEvent; this.pending = new Map(); this.decode = wireDecoder()
channel.addEventListener('message', event => {
if (typeof event.data !== 'string' || event.data.length > 524288) { channel.close(); return }
let frame
try { frame = this.decode(event.data) } catch { channel.close(); return }
if (!frame) return
if (frame.event) { onEvent(frame); return }
const entry = this.pending.get(frame.id)
if (!entry) return
this.pending.delete(frame.id); clearTimeout(entry.timer)
frame.error ? entry.reject(new Error(frame.error)) : entry.resolve(frame.result)
})
channel.addEventListener('close', () => {
for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('IDE bağlantısı kesildi. Gönderilen işi geçmişten doğrulayın.')) }
this.pending.clear(); onEvent({ event: 'disconnected' })
})
}
call(action, params = {}) {
if (this.channel.readyState !== 'open' || this.channel.bufferedAmount > 524288)
return Promise.reject(new Error('IDE bağlantısı hazır değil.'))
if (this.pending.size >= 8) return Promise.reject(new Error('Önceki istekler bekleniyor.'))
const id = crypto.randomUUID()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('Yanıt belirsiz. Görevi yeni kimlikle tekrar göndermeyin; geçmişi kontrol edin.')) }, 70000)
this.pending.set(id, { resolve, reject, timer })
try { this.channel.send(JSON.stringify({ id, action, params })) }
catch { this.pending.delete(id); clearTimeout(timer); reject(new Error('Mesaj gönderilemedi.')) }
})
}
}
export function sendFrame(channel, frame) {
const text = JSON.stringify(frame)
if (text.length > 524288 || channel.bufferedAmount > 2097152) { channel.close(); return }
if (text.length <= 14000) { channel.send(text); return }
const id = crypto.randomUUID(), total = Math.ceil(text.length / 8000)
for (let index = 0; index < total; index++) channel.send(JSON.stringify({ chunk: id, index, total, text: text.slice(index * 8000, (index + 1) * 8000) }))
}
export function wireDecoder() {
let current = null
return raw => {
const frame = JSON.parse(raw)
if (!frame || typeof frame !== 'object') throw new Error('invalid frame')
if (!frame.chunk) { if (current) throw new Error('interleaved frame'); return frame }
if (typeof frame.chunk !== 'string' || frame.chunk.length > 80 || !Number.isInteger(frame.index) ||
!Number.isInteger(frame.total) || frame.total < 1 || frame.total > 66 || typeof frame.text !== 'string' || frame.text.length > 8000)
throw new Error('invalid chunk')
if (!current) { if (frame.index !== 0) throw new Error('missing chunk'); current = { id: frame.chunk, total: frame.total, next: 0, text: '' } }
if (frame.chunk !== current.id || frame.total !== current.total || frame.index !== current.next++) throw new Error('chunk order')
current.text += frame.text
if (current.text.length > 524288) throw new Error('frame too large')
if (current.next < current.total) return null
const result = JSON.parse(current.text); current = null; return result
}
}
export function rtcConfiguration(iceServers = []) {
// Operator-provisioned TURN credentials are optional; no borrowed public relay.
return { iceServers, bundlePolicy: 'max-bundle' }
}
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), certificates: [identity.certificate] })
const channel = pc.createDataChannel('aiturk-mobile-v1', { ordered: true })
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() }
try {
await pc.setLocalDescription(await pc.createOffer()); await completeIce(pc)
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) {
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) => {
if (channel.readyState === 'open') { resolve(); return }
const timer = setTimeout(() => reject(new Error('Bilgisayar yanıt vermedi veya ağ TURN bağlantısı gerektiriyor.')), 20000)
channel.addEventListener('open', () => { clearTimeout(timer); resolve() }, { once: true })
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 }
}