From 4115f1b4c0118adb44badc3461136560071ecb4c Mon Sep 17 00:00:00 2001 From: yilsem Date: Tue, 8 Sep 2026 21:28:24 +0300 Subject: [PATCH] feat(mobile): add local QR scanning and desktop pairing invitations --- apps/desktop/electron/mobile-host-ui.mjs | 3 ++ apps/desktop/electron/mobile-host.html | 2 + apps/desktop/electron/mobile-host.mjs | 3 ++ apps/desktop/scripts/bundle-electron-main.mjs | 4 +- apps/mobile/README.md | 13 +++-- apps/mobile/android/app/build.gradle | 4 +- .../android/app/src/main/AndroidManifest.xml | 2 + apps/mobile/package-lock.json | 20 ++++++-- apps/mobile/package.json | 4 +- apps/mobile/scripts/browser-smoke.mjs | 18 ++++++- apps/mobile/scripts/build.mjs | 4 ++ apps/mobile/src/app.mjs | 25 +++++++++- apps/mobile/src/index.html | 6 ++- apps/mobile/src/invite.mjs | 11 ++++ apps/mobile/src/qr.mjs | 50 +++++++++++++++++++ apps/mobile/src/style.css | 1 + apps/mobile/tests/invite.test.mjs | 25 ++++++++++ 17 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/invite.mjs create mode 100644 apps/mobile/src/qr.mjs create mode 100644 apps/mobile/tests/invite.test.mjs diff --git a/apps/desktop/electron/mobile-host-ui.mjs b/apps/desktop/electron/mobile-host-ui.mjs index b5cfa73..23b1ff3 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -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ı.') }) diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index 0668cbf..0918aea 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -11,6 +11,8 @@


+ +

Telefona izin ver

Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.

diff --git a/apps/desktop/electron/mobile-host.mjs b/apps/desktop/electron/mobile-host.mjs index dc86aa1..b9b229a 100644 --- a/apps/desktop/electron/mobile-host.mjs +++ b/apps/desktop/electron/mobile-host.mjs @@ -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 }) diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs index 959031d..83c168e 100644 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -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')) diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 058f8b9..1e957c6 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -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. diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index bdc6154..425387d 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -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. diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index a6610d0..e409f74 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + =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", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4f0a284..d9975f2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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" } } diff --git a/apps/mobile/scripts/browser-smoke.mjs b/apps/mobile/scripts/browser-smoke.mjs index 1287306..fe7bf44 100644 --- a/apps/mobile/scripts/browser-smoke.mjs +++ b/apps/mobile/scripts/browser-smoke.mjs @@ -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() } diff --git a/apps/mobile/scripts/build.mjs b/apps/mobile/scripts/build.mjs index 79b5c7f..1a22d76 100644 --- a/apps/mobile/scripts/build.mjs +++ b/apps/mobile/scripts/build.mjs @@ -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.') diff --git a/apps/mobile/src/app.mjs b/apps/mobile/src/app.mjs index 9b83dab..67fcd65 100644 --- a/apps/mobile/src/app.mjs +++ b/apps/mobile/src/app.mjs @@ -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.') } diff --git a/apps/mobile/src/index.html b/apps/mobile/src/index.html index 5102947..f24f5ea 100644 --- a/apps/mobile/src/index.html +++ b/apps/mobile/src/index.html @@ -13,6 +13,10 @@ +

Bilgisayarda “Telefon için QR oluştur” seçin. Giriş yaptıktan sonra buradan tarayın. Görüntü sunucuya yüklenmez.

+ + +

Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.

@@ -39,4 +43,4 @@

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.

Veri akışı ve geliştirme sürümünün sınırları

- + diff --git a/apps/mobile/src/invite.mjs b/apps/mobile/src/invite.mjs new file mode 100644 index 0000000..f2cdc00 --- /dev/null +++ b/apps/mobile/src/invite.mjs @@ -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 } +} diff --git a/apps/mobile/src/qr.mjs b/apps/mobile/src/qr.mjs new file mode 100644 index 0000000..3cc7bd4 --- /dev/null +++ b/apps/mobile/src/qr.mjs @@ -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 } +} diff --git a/apps/mobile/src/style.css b/apps/mobile/src/style.css index 23a0e06..1d58772 100644 --- a/apps/mobile/src/style.css +++ b/apps/mobile/src/style.css @@ -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} diff --git a/apps/mobile/tests/invite.test.mjs b/apps/mobile/tests/invite.test.mjs new file mode 100644 index 0000000..1579d5c --- /dev/null +++ b/apps/mobile/tests/invite.test.mjs @@ -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) +})