feat(mobile): add Android development client and scoped IDE companion

This commit is contained in:
2026-09-08 16:54:28 +03:00
parent 3332458708
commit 9f9f3226a1
84 changed files with 4371 additions and 4 deletions
+130
View File
@@ -0,0 +1,130 @@
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 = []) {
if (!iceServers.length) iceServers = (await account.call('/v1/aiturk/mobile/ice')).iceServers
const pc = new RTCPeerConnection(rtcConfiguration(iceServers))
const channel = pc.createDataChannel('aiturk-mobile-v1', { ordered: true })
const requests = new DataRequests(channel, onEvent)
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) { 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)
return { requests, close, pc, id }
} catch (error) { close(); throw error }
}