feat(mobile): add local QR scanning and desktop pairing invitations
This commit is contained in:
1 parent
c1929957a2
commit
4115f1b4c0
17 files changed
+178
-17
No files matched your search
@@ -1,10 +1,13 @@
|
||||
import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs'
|
||||
import { drawQr } from './mobile/qr.mjs'
|
||||
const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map()
|
||||
const status = text => { $('status').textContent = text }
|
||||
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
|
||||
action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` })
|
||||
action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') })
|
||||
action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') })
|
||||
action('invite', async () => { const invite = await host.call('invite.create'); drawQr($('invite-qr'), JSON.stringify(invite)); $('invite-qr').hidden = false; $('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}` })
|
||||
action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) })
|
||||
action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') })
|
||||
action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` })
|
||||
action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase() }); $('phone-code').value = ''; status('Telefon bu bilgisayarda onaylandı.') })
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<label>AITURK hesap API anahtarı<input id="account" type="password" autocomplete="off"></label>
|
||||
<button id="save">Hesap anahtarını kaydet</button></details><button id="pair">Bu bilgisayarı hesabımla eşleştir</button>
|
||||
<p id="code"></p><hr>
|
||||
<button id="invite">Telefon için QR oluştur</button><canvas id="invite-qr" hidden aria-label="Süreli telefon eşleştirme QR kodu"></canvas>
|
||||
<p id="invite-status"></p><button id="invite-check">QR ile gelen telefonu getir</button>
|
||||
<h2>Telefona izin ver</h2><p>Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.</p>
|
||||
<label>Eşleştirme kimliği<input id="pairing"></label><label>Telefondaki kod<input id="phone-code" maxlength="8" autocapitalize="characters"></label>
|
||||
<button id="approve">Bu telefonu onayla</button><button id="listen">Mobil bağlantıyı aç</button>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
}
|
||||
if (action === 'account.login.check') {
|
||||
const previous = await vault.load()
|
||||
if (previous.accountToken || clients.size) throw new Error('Mevcut hesap veya bağlantı sessizce değiştirilemez.')
|
||||
if (!previous.pendingLogin) throw new Error('Önce giriş başlatın.')
|
||||
const result = await new AccountClient({ accountToken: previous.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
|
||||
if (result.state !== 'approved') return { approved: false }
|
||||
@@ -70,6 +71,8 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t
|
||||
return call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code })
|
||||
}
|
||||
if (action === 'devices') return call('/v1/aiturk/mobile/devices')
|
||||
if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {})
|
||||
if (action === 'invite.check') return call('/v1/aiturk/mobile/invites')
|
||||
if (action === 'device.revoke') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(params.deviceId) || !Number.isInteger(params.version)) throw new Error('Geçersiz cihaz.')
|
||||
const result = await call(`/v1/aiturk/devices/${params.deviceId}/revoke`, { expected_version: params.version })
|
||||
|
||||
@@ -67,6 +67,8 @@ for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cj
|
||||
cpSync(resolve(root, 'electron', name), resolve(distDir, name))
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile'), { recursive: true })
|
||||
for (const name of ['transport.mjs', 'style.css']) {
|
||||
for (const name of ['transport.mjs', 'style.css', 'qr.mjs']) {
|
||||
cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name))
|
||||
}
|
||||
mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true })
|
||||
cpSync(resolve(root, '../mobile/node_modules/qrcode-generator/dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs'))
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+17
-3
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
@@ -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.') }
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user