147 lines
8.4 KiB
JavaScript
147 lines
8.4 KiB
JavaScript
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 }
|
||
}
|