feat(mobile): add local QR scanning and desktop pairing invitations

This commit is contained in:
yilsem committed 2026-09-08 21:28:24 +03:00
1 parent c1929957a2
commit 4115f1b4c0
17 files changed
+178 -17

No files matched your search

+8 -5
View File
@@ -1,4 +1,4 @@
# AITURK Mobil — Android geliştirme sürümü 0.1.1
# AITURK Mobil — Android geliştirme sürümü 0.1.2
Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir.
Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı.
@@ -55,8 +55,10 @@ Mobil pencere açılmadan uzaktan erişim etkinleşmez.
1. Bilgisayardaki mobil pencerede “AITURK hesabımla giriş yap” seçin; tarayıcıda
hesabınıza girip uygulamanın kodunu onaylayın. “Girişi kontrol et” ile tamamlayın.
2. Bilgisayarı hesapla eşleştirin; açılan mevcut portal sayfasında kodu onaylayın.
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayıp eşleştirme kodu oluşturun.
4. Telefondaki kimlik/kodu bilgisayardaki mobil pencerede açıkça onaylayın.
3. Android'de aynı AITURK hesabıyla tarayıcı kod girişini tamamlayın. Bilgisayarda
“Telefon için QR oluştur” seçip telefonda kamerayla veya QR resmiyle okuyun.
4. Bilgisayarda “QR ile gelen telefonu getir” seçin; telefonun gösterdiği kısa
kodu girip açıkça onaylayın. Uzun kimliği elle yazmak gerekmez.
5. Bilgisayarda “Mobil bağlantıyı aç”, telefonda “Bilgisayarları bul” ve “Bağlan”.
6. Gerçek IDE konuşmasını seçin. Yazılı görevi görünür önizlemeden gönderin.
7. Google anahtarını telefondaki ayrı güvenli girişten bilgisayara aktarın.
@@ -92,8 +94,9 @@ Henüz tamamlanmayanlar:
- Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma
senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor.
- Canlı gateway/TURN kurulumu, ayrı mobil ve bilgisayar ağlarıyla tam akış.
- QR tarama. Tarayıcı kısa kod girişi kodlandı ve HTTP/DB ile sahte yanıtlı
tarayıcı arayüz testleri geçti; gerçek Android giriş kabulü henüz yapılmadı.
- Giriş ve QR artık kodlandı: 9 HTTP/DB testi, 27 mobil testi, gerçek QR pikseli
okuyan (hesap/claim yanıtları sahte) tarayıcı akışı geçti. Fiziksel Android
kamera/giriş kabulü ve farklı ağ bağlantısı henüz yapılmadı.
- Açık talimatla prepare_task/submit_task devri ve görev kimlikli terminal
olayları kodlandı. 25 Node testi ve üç görevli gerçek yerel IDE bağlantı
testi geçti. Canlı Google transkript sırası ve Türkçe kalite kabulü henüz yok.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "online.turkservis.aiturk.mobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2
versionName "0.1.1-dev"
versionCode 3
versionName "0.1.2-dev"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
+17 -3
View File
@@ -1,15 +1,17 @@
{
"name": "aiturk-mobile",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aiturk-mobile",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@capacitor/android": "8.5.1",
"@capacitor/core": "8.5.1"
"@capacitor/core": "8.5.1",
"jsqr": "1.4.0",
"qrcode-generator": "2.0.4"
},
"devDependencies": {
"@capacitor/cli": "8.5.1"
@@ -637,6 +639,12 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -827,6 +835,12 @@
"node": ">=6"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aiturk-mobile",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
@@ -8,6 +8,6 @@
"test": "node --test tests/*.test.mjs",
"android:sync": "npm run build && cap sync android"
},
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1" },
"dependencies": { "@capacitor/core": "8.5.1", "@capacitor/android": "8.5.1", "jsqr": "1.4.0", "qrcode-generator": "2.0.4" },
"devDependencies": { "@capacitor/cli": "8.5.1" }
}
+17 -1
View File
@@ -37,6 +37,22 @@ try {
await page.getByText('Yalnız kendi hesabınızda bu kodu onaylayın: ABCDEFGH').waitFor()
await page.getByRole('button', { name: 'Girişi kontrol et' }).click()
await page.getByText('Hesaba giriş yapıldı.', { exact: true }).waitFor()
let claimed = false
await page.route('https://turkservis.online/v1/aiturk/mobile/invites/**/claim', async route => {
claimed = true
const body = route.request().postDataJSON(); assert.equal(body.secret, 'a'.repeat(43))
await route.fulfill({ status: 201, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify({ device_id: 'fixture-phone', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
})
const qrImage = await page.evaluate(async () => {
const { drawQr } = await import('./qr.mjs'), canvas = document.createElement('canvas')
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 1, inviteId: '11111111-1111-4111-8111-111111111111',
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(Date.now() + 600000).toISOString() }))
return canvas.toDataURL('image/png').split(',')[1]
})
await page.locator('#qr-file').setInputFiles({ name: 'invite.png', mimeType: 'image/png', buffer: Buffer.from(qrImage, 'base64') })
await page.getByText('Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: QRSTUVWX').waitFor()
assert.equal(claimed, true)
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
@@ -67,5 +83,5 @@ try {
})
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
assert.deepEqual(errors, [])
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realWebRTC: result, errors }))
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed', realWebRTC: result, errors }))
} finally { await browser.close(); server.close() }
+4
View File
@@ -5,4 +5,8 @@ await mkdir(new URL('../dist', import.meta.url), { recursive: true })
await cp(new URL('../src', import.meta.url), new URL('../dist', import.meta.url), { recursive: true })
const core = dirname(createRequire(import.meta.url).resolve('@capacitor/core/package.json'))
await cp(join(core, 'dist', 'capacitor.js'), new URL('../dist/capacitor.js', import.meta.url))
await mkdir(new URL('../dist/vendor', import.meta.url), { recursive: true })
await cp(new URL('../node_modules/qrcode-generator/dist/qrcode.mjs', import.meta.url), new URL('../dist/vendor/qrcode.mjs', import.meta.url))
await cp(new URL('../node_modules/jsqr/dist/jsQR.js', import.meta.url), new URL('../dist/vendor/jsQR.js', import.meta.url))
await cp(new URL('../node_modules/jsqr/LICENSE', import.meta.url), new URL('../dist/vendor/jsQR-LICENSE.txt', import.meta.url))
console.log('AITURK Android web assets built.')
+23 -2
View File
@@ -1,6 +1,8 @@
import { AccountClient, openPhoneConnection } from './transport.mjs'
import { VoiceSession } from './voice.mjs'
import { taskState } from './behavior.mjs'
import { parseInvite } from './invite.mjs'
import { InviteScanner } from './qr.mjs'
const $ = id => document.getElementById(id)
if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets')
let credentials = {}, account, connection, selected, approval, voice, pendingTask, usageTimer, voiceGeneration = 0
@@ -8,6 +10,23 @@ let connectedDesktopId = null
let connectionEpoch = 0, selectionEpoch = 0
const status = text => { $('status').textContent = text }
const native = () => window.Capacitor?.Plugins?.MobileSecrets
const scanner = new InviteScanner($('qr-video'), async raw => {
if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; kayıtlı cihaz varsa önce mevcut eşleşmeyi kontrol edin.')
const targetAccount = account, epoch = connectionEpoch
const invite = parseInvite(raw)
const result = await account.call(`/v1/aiturk/mobile/invites/${invite.inviteId}/claim`, {
secret: invite.secret, display_name: $('device-name').value.trim() })
if (account !== targetAccount || epoch !== connectionEpoch) return
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token,
pairingId: result.pairing_id, preferredDesktopId: invite.desktopId })
await saveCredentials()
$('pair-code').textContent = `Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: ${result.user_code}`
status('QR okundu. Bilgisayardaki açık telefon onayı bekleniyor.')
})
scanner.onError = error => status(error.message)
action('scan-invite', async () => { if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; bu telefon henüz eşleştirilmemiş olmalı.'); await scanner.start() })
action('stop-scan', async () => scanner.stop())
$('qr-file').onchange = async event => { try { await scanner.image(event.target.files[0]) } catch (error) { status(error.message) } finally { event.target.value = '' } }
async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) }
function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() }
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
@@ -41,6 +60,7 @@ action('login-start', async () => {
status('Tarayıcıda giriş yaptıktan sonra buraya dönüp girişi kontrol edin.')
})
action('login-check', async () => {
if (credentials.accountToken) throw new Error('Hesaba zaten giriş yapılmış; mevcut hesap değiştirilmeyecek.')
if (!credentials.pendingLogin) throw new Error('Önce giriş başlatın.')
const result = await new AccountClient({ accountToken: credentials.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
if (result.state !== 'approved') { status('Tarayıcıdaki kod onayı bekleniyor.'); return }
@@ -49,6 +69,7 @@ action('login-check', async () => {
status('Hesap bağlı. Şimdi telefonun eşleştirme kodunu oluşturun.')
})
action('logout', async () => {
scanner.stop()
const token = credentials.accountToken || credentials.pendingLogin?.deviceCode
// Revoke the app-specific credential before dropping the local copy. A
// network failure leaves it available for an explicit retry.
@@ -166,6 +187,6 @@ action('history', async () => { const result = await rpc('session.history', { se
action('clear-history', async () => { $('discussion').replaceChildren(); $('result').replaceChildren(); status('Telefondaki görünür metin temizlendi. Google bağlantı bağlamını da temizlemek için görüşmeyi kapatın.') })
action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; $('work').hidden = true; status('Telefon bağlantısı kapalı. IDE işi devam edebilir.') })
action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') })
document.addEventListener('visibilitychange', () => { if (document.hidden) stopVoice() })
window.addEventListener('pagehide', () => { stopVoice(); connection?.close() })
document.addEventListener('visibilitychange', () => { if (document.hidden) { stopVoice(); scanner.stop() } })
window.addEventListener('pagehide', () => { stopVoice(); scanner.stop(); connection?.close() })
try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') }
+5 -1
View File
@@ -13,6 +13,10 @@
<label>AITURK hesap API anahtarı<input id="account-token" type="password" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
</details>
<label>Telefon adı<input id="device-name" maxlength="80" value="Android telefonum"></label><button id="pair">Eşleştirme kodu oluştur</button>
<p>Bilgisayarda “Telefon için QR oluştur” seçin. Giriş yaptıktan sonra buradan tarayın. Görüntü sunucuya yüklenmez.</p>
<button id="scan-invite">Bilgisayarın QR kodunu tara</button><button id="stop-scan" class="secondary">Kamerayı kapat</button>
<video id="qr-video" hidden muted playsinline aria-label="QR kamera önizlemesi"></video>
<label>Alternatif: QR resmi seç<input id="qr-file" type="file" accept="image/*"></label>
<pre id="pair-code" aria-live="polite"></pre><p>Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.</p>
<button id="devices" class="secondary">Onaydan sonra bilgisayarları bul</button>
<label>Bilgisayar<select id="desktop"><option value="">Önce cihazları bulun</option></select></label><button id="connect">Bilgisayara bağlan</button>
@@ -39,4 +43,4 @@
<button id="revoke" class="danger">Bu telefonun erişimini iptal et</button>
</section><hr><p>Ses kaydı saklanmaz. Google görüşme metni yalnız bu ekranın belleğinde kalır. IDE'ye gönderilen görev IDE geçmişinde saklanır. Ekrandan çıkmak mikrofonu kapatır; IDE işini durdurmaz.</p>
<p><a href="privacy.html">Veri akışı ve geliştirme sürümünün sınırları</a></p>
</main><script src="capacitor.js"></script><script type="module" src="app.mjs"></script></body></html>
</main><script src="capacitor.js"></script><script src="vendor/jsQR.js"></script><script type="module" src="app.mjs"></script></body></html>
+11
View File
@@ -0,0 +1,11 @@
export function parseInvite(raw, now = Date.now()) {
if (typeof raw !== 'string' || raw.length > 800) throw new Error('Bu bir AITURK eşleştirme QR kodu değil.')
let data
try { data = JSON.parse(raw) } catch { throw new Error('Bu bir AITURK eşleştirme QR kodu değil.') }
if (!data || data.kind !== 'aiturk-mobile-invite' || data.version !== 1 ||
!/^[0-9a-f-]{36}$/.test(data.inviteId) || !/^[0-9a-f-]{36}$/.test(data.desktopId) ||
!/^[A-Za-z0-9_-]{43}$/.test(data.secret) || !Number.isFinite(Date.parse(data.expiresAt)) ||
Date.parse(data.expiresAt) <= now || Date.parse(data.expiresAt) > now + 11 * 60000)
throw new Error('QR geçersiz veya süresi dolmuş. Bilgisayarda yeni kod oluşturun.')
return { inviteId: data.inviteId, desktopId: data.desktopId, secret: data.secret }
}
+50
View File
@@ -0,0 +1,50 @@
import qrcode from './vendor/qrcode.mjs'
export function drawQr(canvas, text) {
const qr = qrcode(0, 'M'); qr.addData(text); qr.make()
const count = qr.getModuleCount(), scale = 5, margin = 4
canvas.width = canvas.height = (count + margin * 2) * scale
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#000'
for (let y = 0; y < count; y++) for (let x = 0; x < count; x++)
if (qr.isDark(y, x)) ctx.fillRect((x + margin) * scale, (y + margin) * scale, scale, scale)
}
export class InviteScanner {
constructor(video, onCode) { this.video = video; this.onCode = onCode; this.epoch = 0; this.canvas = document.createElement('canvas') }
decode(source, width, height) {
if (!width || !height) return null
const scale = Math.min(1, 1280 / Math.max(width, height))
this.canvas.width = Math.round(width * scale); this.canvas.height = Math.round(height * scale)
const ctx = this.canvas.getContext('2d', { willReadFrequently: true })
ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height)
const pixels = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
return globalThis.jsQR(pixels.data, pixels.width, pixels.height)?.data
}
async image(file) {
this.stop(); const epoch = this.epoch
if (!file || file.size > 8 * 1024 * 1024) throw new Error('QR resmi en fazla 8 MB olabilir.')
const bitmap = await createImageBitmap(file)
let result
try { result = this.decode(bitmap, bitmap.width, bitmap.height) } finally { bitmap.close() }
if (epoch !== this.epoch) return
if (!result) throw new Error('Resimde QR okunamadı; daha net bir görüntü deneyin.')
await this.onCode(result)
}
async start() {
this.stop(); const epoch = this.epoch
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
if (epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); return }
this.stream = stream; this.video.srcObject = stream; this.video.hidden = false
try {
await this.video.play()
if (epoch !== this.epoch) return
this.timer = setInterval(() => {
const result = this.decode(this.video, this.video.videoWidth, this.video.videoHeight)
if (result) { this.stop(); void this.onCode(result).catch(error => this.onError?.(error)) }
}, 250)
this.limit = setTimeout(() => this.stop(), 60000)
} catch (error) { this.stop(); throw error }
}
stop() { this.epoch++; clearInterval(this.timer); clearTimeout(this.limit); this.stream?.getTracks().forEach(t => t.stop()); this.stream = null; this.video.srcObject = null; this.video.hidden = true }
}
+1
View File
@@ -8,3 +8,4 @@ button.secondary { background:var(--surface); color:var(--text); border:1px soli
:focus-visible { outline:3px solid var(--accent); outline-offset:4px } a { color:var(--accent) } hr { border:0; border-top:1px solid var(--stroke); margin:30px 0 } pre { white-space:pre-wrap; word-break:break-word; font:inherit } [hidden] { display:none!important }
.status { border-left:3px solid var(--accent); padding:10px 14px; color:var(--text); background:var(--surface) } .row { display:flex; align-items:center; flex-wrap:wrap; gap:4px } .row button { flex:1 } .history { max-height:340px; overflow:auto; padding:8px 0 } .history p { white-space:pre-wrap; overflow-wrap:anywhere } small { color:var(--muted) } input[type=checkbox] { width:24px; height:24px; vertical-align:middle; margin:0 8px 0 0 }
@media(prefers-reduced-motion:no-preference) { button { transition:background-color .1s } }
canvas,video{max-width:100%;height:auto}video{border-radius:12px}canvas{image-rendering:pixelated}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import qrcode from 'qrcode-generator'
import jsQR from 'jsqr'
import { parseInvite } from '../src/invite.mjs'
const invite = { version: 1, kind: 'aiturk-mobile-invite', inviteId: '11111111-1111-4111-8111-111111111111',
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(600000).toISOString() }
test('invitation parser refuses URLs, expired and unknown invitations', () => {
assert.equal(parseInvite(JSON.stringify(invite), 0).desktopId, invite.desktopId)
for (const bad of ['https://evil.test', JSON.stringify({ ...invite, version: 2 }), JSON.stringify({ ...invite, secret: 'short' })])
assert.throws(() => parseInvite(bad, 0))
assert.throws(() => parseInvite(JSON.stringify(invite), 600000))
})
test('bundled QR encoder and decoder round trip the actual invitation pixels', () => {
const qr = qrcode(0, 'M'); qr.addData(JSON.stringify(invite)); qr.make()
const n = qr.getModuleCount(), size = (n + 8) * 5, pixels = new Uint8ClampedArray(size * size * 4).fill(255)
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) {
for (let sy = 0; sy < 5; sy++) for (let sx = 0; sx < 5; sx++) {
const i = (((y + 4) * 5 + sy) * size + (x + 4) * 5 + sx) * 4
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0
}
}
assert.deepEqual(JSON.parse(jsQR(pixels, size, size).data), invite)
})