feat(mobile): add Android development client and scoped IDE companion
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user