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 } }