diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 340057a..d9bd9c2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9,6 +9,7 @@ import tls from 'node:tls' import { pathToFileURL, fileURLToPath } from 'node:url' import { startChromeBridge } from './chrome-bridge.mjs' import { createMobileWindow } from './mobile-host.mjs' +import { MobileVault } from './mobile-secrets.mjs' import { app, @@ -34,6 +35,14 @@ import { import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' +import { + createMobileLink, + createMobileLinkFileStore, + createWindowsFirewall, + defaultRouteAddress, + type MobileLink, + selectLanAddresses +} from './mobile-lan-proxy' import { describeVcRuntimeResult, ensureWindowsVcRuntime } from './windows-vc-runtime' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { appIconCandidates, resolveAppIcon } from './app-icon' @@ -17818,19 +17827,90 @@ function openMobileConnectionWindow() { return } mobileConnectionWindow = createMobileWindow({ - directory: path.join(app.getPath('userData'), 'aiturk-mobile'), - assets: path.dirname(fileURLToPath(import.meta.url)), - resolveBackend: async () => { - const profile = primaryProfileKey() - if (globalRemoteActive() || profileHasRemoteOverride(profile)) throw new Error('Mobil bağlantı için yerel IDE bağlantısını seçin.') - return ensureBackend(profile) - } - }) + assets: path.dirname(fileURLToPath(import.meta.url)), + link: getMobileLink() + }) } +// "Telefon bağlantısı" (electron/mobile-lan-proxy.ts): TLS LAN proxy in front of +// the loopback backend + pairing. One controller for the whole app run; it only +// listens while the feature is on and a phone is paired or being paired. +let mobileLink: MobileLink | null = null + +function mobileLinkRemoteMode() { + return globalRemoteActive() || profileHasRemoteOverride(primaryProfileKey()) +} + +function getMobileLink(): MobileLink { + if (mobileLink) { + return mobileLink + } + + const directory = path.join(app.getPath('userData'), 'aiturk-mobile', 'mobile') + + mobileLink = createMobileLink({ + // Same vault file as before (mobile-secrets.bin, safeStorage); older fields are kept. + vault: new MobileVault(directory, safeStorage), + store: createMobileLinkFileStore(directory, path.join(directory, 'lan-link.json'), fs.promises), + resolveBackend: async () => { + if (mobileLinkRemoteMode()) { + throw new Error('aiturk-mobile:remote_mode') + } + + const connection = await ensureBackend(primaryProfileKey()) + + if (connection.mode !== 'local') { + throw new Error('aiturk-mobile:remote_mode') + } + + return { baseUrl: connection.baseUrl, token: connection.token } + }, + isRemoteMode: mobileLinkRemoteMode, + firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }), + fetch: (url, init) => fetch(url, init as RequestInit), + addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()), + version: app.getVersion(), + pcName: os.hostname(), + platform: process.platform, + // Method + path + status only (no token, code, query or body). Successful + // API traffic is not logged; pairing and refusals are, for diagnosis. + log: line => { + if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { + rememberLog(`[mobile-lan] ${line}`) + } + } + }) + + return mobileLink +} + +// Only the primary window's top-level frame may open the phone window. +ipcMain.handle('aiturk:mobile:open', async event => { + assertAiturkLoginSender(event) + + if (process.platform !== 'win32') { + throw new Error('aiturk-mobile:unsupported') + } + + openMobileConnectionWindow() + + return { ok: true } +}) + +app.on('will-quit', () => { + void mobileLink?.stop().catch(() => undefined) +}) + app.whenReady().then(() => { // Explicit development/beta entry point; never enables remote access on boot. if (process.platform === 'win32' && process.argv.includes('--aiturk-mobile')) openMobileConnectionWindow() + // Resume the phone link only when the user turned it on earlier AND a phone + // is paired; otherwise nothing listens (and no vault / UAC prompt is touched). + if (process.platform === 'win32') { + void getMobileLink() + .start() + .catch(error => rememberLog(`[mobile-lan] start failed: ${error instanceof Error ? error.message : 'error'}`)) + } if (process.platform === 'win32' && process.env.LOCALAPPDATA) { void startChromeBridge({ directory: path.join(process.env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE'), diff --git a/apps/desktop/electron/mobile-host-ui.mjs b/apps/desktop/electron/mobile-host-ui.mjs index 84b2b87..c039b31 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -1,94 +1,135 @@ -import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs' -import { drawQr, InviteScanner } from './mobile/qr.mjs' -import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.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 } } } -const identityScanner = new InviteScanner(document.createElement('video'), async raw => { - if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.') - const value = JSON.parse(raw) - const pairingId = $('pairing').value.trim() || value.pairingId - parsePhoneIdentity(raw, pairingId) - if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.') - $('pairing').value = pairingId; $('phone-identity').value = raw - status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.') -}) -$('phone-identity-file').onchange = async event => { - try { await identityScanner.image(event.target.files[0]) } - catch (error) { status(error.message) } - finally { event.target.value = '' } +// "Telefon bağlantısı" window UI. Talks to electron/mobile-host.mjs over the +// `mobileHost` bridge (mobile-preload.cjs); all logic lives in the main process. +/* global window, document */ +import { drawQr } from './mobile/qr.mjs' + +const TEXT = { + tr: { + eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı', + intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.', + enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı', + remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.', + firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', + firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', + firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.', + retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç', + scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.', + orCode: 'ya da telefonda şu kodu yazın:', + codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.', + codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', + sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.', + countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.', + devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.', + added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır', + removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`, + footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.', + errors: { + remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.', + vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.', + not_enabled: 'Önce telefon bağlantısını açın.', + no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.', + port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).', + failed: 'İşlem tamamlanamadı. Yeniden deneyin.', + }, + }, + en: { + eyebrow: 'AITURK · COMPUTER', title: 'Phone connection', + intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.', + enabled: 'Phone connection is on', disabled: 'Phone connection is off', + remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.', + firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.', + firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.', + firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.', + retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel', + scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.', + orCode: 'or type this code on the phone:', + codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.', + codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', + sameWifi: 'The phone and the computer must be on the same Wi-Fi network.', + countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.', + devices: 'Connected phones', noDevices: 'No phone is connected yet.', + added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove', + removed: name => `${name} was removed.`, paired: name => `${name} connected.`, + footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.', + errors: { + remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.', + vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.', + not_enabled: 'Turn phone connection on first.', + no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).', + port_unavailable: 'No free port was found (47321–47340 are in use).', + failed: 'That did not work. Try again.', + }, + }, } -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.') }) -async function desktopIdentity(create = false) { - const setup = await host.call('setup') - if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.') - if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint) - if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.') - const identity = await createDeviceIdentity(setup.deviceId) - await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint }) - return identity +const lang = (navigator.language || '').toLowerCase().startsWith('en') ? 'en' : 'tr' +const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementById(id) +const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' }) +document.documentElement.lang = lang; document.title = `AITURK · ${t.title}` +for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'], + ['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'], + ['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key] + +let current = null, drawnQr = '', busy = false +function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text } +const errorText = error => t.errors[/aiturk-mobile:([a-z_]+)/.exec(String(error?.message))?.[1]] || t.errors.failed + +function tick() { + const pairing = current?.pairing + if (!pairing) return + const left = Math.max(0, Math.ceil((pairing.expiresAt - Date.now()) / 1000)) + $('pair-countdown').textContent = t.countdown(Math.floor(left / 60), String(left % 60).padStart(2, '0')) + if (left === 0) { notice(t.expired); void refresh() } } -action('invite', async () => { - const identity = await desktopIdentity(true), invite = await host.call('invite.create') - drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint })) - $('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(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') }) -action('refresh', async () => { - const data = await host.call('devices'); $('devices').replaceChildren() - for (const device of data.devices) { - const row = document.createElement('p'), label = document.createElement('span'), button = document.createElement('button') - label.textContent = `${device.name} · ${device.platform}${device.self ? ' · Bu bilgisayar' : ''}` - button.textContent = 'Cihazı iptal et' - button.onclick = async () => { button.disabled = true; try { await host.call('device.revoke', { deviceId: device.id, version: device.version }); row.remove(); status('Cihaz iptal edildi.') } catch (error) { status(error.message); button.disabled = false } } - row.append(label, button); $('devices').append(row) - } -}) -function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) } -host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) }) -async function connect(offer) { - assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint) - const ice = await host.call('ice') - const identity = await desktopIdentity() - const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry) - pc.ondatachannel = event => { - const channel = event.channel; entry.channel = channel - if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return } - let pending = 0 - channel.onmessage = async event => { - if (typeof event.data !== 'string' || event.data.length > 65000 || ++pending > 8) { close(offer.id); return } - let message - try { - message = JSON.parse(event.data) - const result = await host.call('channel.request', { channelId: offer.id, message }) - if (channel.readyState === 'open') sendFrame(channel, { id: message.id, result }) - } catch (error) { if (message?.id && channel.readyState === 'open') channel.send(JSON.stringify({ id: message.id, error: error.message })) } - finally { pending-- } - } - channel.onclose = () => close(offer.id) - channel.onopen = () => status('Telefon şifreli veri kanalıyla bağlı.') - } - try { - await pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp }) - await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc) - await host.call('channel.answer', { channelId: offer.id, sdp: pc.localDescription.sdp }) - entry.timer = setInterval(() => { void host.call('channel.heartbeat', { channelId: offer.id }).catch(() => close(offer.id)) }, 10000) - } catch (error) { close(offer.id); throw error } + +function render(state) { + current = state + $('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled + $('remote-box').hidden = !state.remote + const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall] + $('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || '' + $('pair-section').hidden = !state.enabled || state.remote + $('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing + if (state.pairing) { + if (drawnQr !== state.pairing.qr) { drawQr($('pair-qr'), state.pairing.qr); drawnQr = state.pairing.qr } + $('pair-qr').setAttribute('aria-label', t.scan) + $('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}` + $('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath] + tick() + } else { drawnQr = ''; $('pair-code').textContent = '' } + $('devices-section').hidden = !state.enabled && state.devices.length === 0 + $('devices-empty').hidden = state.devices.length > 0 + $('devices').replaceChildren(...state.devices.map(device => { + const row = document.createElement('div'), label = document.createElement('div'), name = document.createElement('strong') + const added = document.createElement('small'), seen = document.createElement('small'), button = document.createElement('button') + row.className = 'device'; name.textContent = device.name + added.textContent = `${t.added}: ${when(device.createdAt)}` + seen.textContent = device.live > 0 ? t.online : `${t.lastSeen}: ${when(device.lastSeenAt)}` + button.textContent = t.remove; button.className = 'danger' + button.onclick = () => run(async () => { render(await host.call('device.revoke', { deviceId: device.id })); notice(t.removed(device.name)) }) + label.append(name, added, seen); row.append(label, button) + return row + })) + $('footer').hidden = state.port === null + $('footer-info').textContent = state.port === null ? '' : t.footer(state.port) + $('firewall-remove').hidden = state.firewall !== 'ok' } -let listening = false -action('listen', async () => { - if (listening) return - listening = true; status('Onaylı telefon bağlantısı bekleniyor.') - while (listening) { - try { const data = await host.call('inbox'); for (const offer of data.channels) if (!peers.has(offer.id)) await connect(offer) } - catch (error) { status(error.message); listening = false; break } - await new Promise(resolve => setTimeout(resolve, 2000)) - } -}) -window.addEventListener('pagehide', () => { listening = false; for (const id of peers.keys()) close(id) }) + +async function run(work) { + if (busy) return + busy = true; document.body.setAttribute('aria-busy', 'true') + for (const button of document.querySelectorAll('button,input')) button.disabled = true + try { await work() } catch (error) { notice(errorText(error)); await refresh() } + finally { busy = false; document.body.removeAttribute('aria-busy'); for (const button of document.querySelectorAll('button,input')) button.disabled = false } +} +async function refresh() { try { render(await host.call('state')) } catch (error) { notice(errorText(error)) } } + +$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) } +$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) }) +$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) }) +$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) }) +$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) }) +host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) }) +setInterval(tick, 1000) +// "Last seen" and the live marker change without an event; keep the list fresh while the window is open. +setInterval(() => { if (!busy && current?.enabled) void refresh() }, 15000) +void refresh() diff --git a/apps/desktop/electron/mobile-host.css b/apps/desktop/electron/mobile-host.css new file mode 100644 index 0000000..58b2959 --- /dev/null +++ b/apps/desktop/electron/mobile-host.css @@ -0,0 +1,16 @@ +/* "Telefon bağlantısı" window; builds on apps/mobile/src/style.css (copied to dist/mobile/). */ +.switch { display:flex; align-items:center; gap:10px; margin:22px 0; font-size:1.1rem; cursor:pointer } +.switch input { width:26px; height:26px; margin:0; flex:none } +.warning { border-left:3px solid var(--danger); background:var(--surface); padding:10px 14px; margin:16px 0 } +.warning p { color:var(--text); margin:4px 0 } +.code { font-family:ui-monospace,Consolas,monospace; font-size:2.6rem; font-weight:750; letter-spacing:.12em; color:var(--text); text-align:center; margin:10px 0; user-select:all } +.countdown { text-align:center; color:var(--text); font-weight:600 } +.hint { text-align:center } +#pair-qr { display:block; margin:14px auto; border-radius:8px; width:260px; height:260px } +#pair-live p { text-align:center } +.device { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--stroke); border-radius:8px; padding:10px 14px; margin:10px 0 } +.device strong { color:var(--text); display:block; overflow-wrap:anywhere } +.device small { display:block } +.device button { flex:none; margin:0 } +footer { margin-top:34px } +button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline } diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index b2ba6ea..5194367 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -1,24 +1,27 @@ - -AITURK Mobil bağlantısı
-

AITURK · BİLGİSAYAR

Telefonunu bağla.

-

Bu pencere açıkken eşleştirdiğiniz Android telefon seçtiğiniz IDE konuşmasına erişebilir. Google anahtarı Windows güvenli kasasında tutulur.

-

Bağlantı kapalı.

-

-

Çıkış, mobil bağlantıları kapatır ve bu penceredeki hesap/Google anahtarını Windows kasasından temizler; başlamış IDE işini durdurmaz.

- -
Gelişmiş: hesap API anahtarı - -
-


- -

-

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.

- - - -

Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.

- -

Cihazlar

-

Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.

-
+ +AITURK · Telefon bağlantısı
+

AITURK · BİLGİSAYAR

Telefon bağlantısı

+

+ + + + + + + +
diff --git a/apps/desktop/electron/mobile-host.mjs b/apps/desktop/electron/mobile-host.mjs index 662bdb0..69ccaae 100644 --- a/apps/desktop/electron/mobile-host.mjs +++ b/apps/desktop/electron/mobile-host.mjs @@ -1,157 +1,45 @@ +// "Telefon bağlantısı" window: a thin IPC shell around the controller built in +// electron/mobile-lan-proxy.ts (createMobileLink). The controller lives in the +// main process for the whole app run — closing this window only cancels a +// pairing in progress; paired phones stay connected. import { join } from 'node:path' -import { hostname } from 'node:os' -import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron' -import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs' -import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs' -import { AccountClient } from '../../mobile/src/transport.mjs' -import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs' +import { BrowserWindow, ipcMain } from 'electron' -export function createMobileWindow({ directory, assets, resolveBackend, show = true }) { - const vault = new MobileVault(join(directory, 'mobile'), safeStorage) - const journal = new DeliveryJournal(join(directory, 'mobile', 'deliveries')) - const issuer = new LiveTokenIssuer(vault) - const clients = new Map() - const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK Mobil bağlantısı', +const CODED = /^aiturk-mobile:[a-z_]+$/ + +export function createMobileWindow({ assets, link, show = true }) { + const window = new BrowserWindow({ width: 560, height: 800, show, title: 'AITURK · Telefon bağlantısı', webPreferences: { preload: join(assets, 'mobile-preload.cjs'), contextIsolation: true, - nodeIntegration: false, sandbox: true, backgroundThrottling: false } }) + nodeIntegration: false, sandbox: true } }) window.setMenu(null) window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) window.webContents.on('will-navigate', event => event.preventDefault()) - const api = async () => new AccountClient(await vault.load()) - const call = async (path, body, method) => (await api()).call(path, body, method) - const send = frame => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', frame) } - const handler = async (event, action, params = {}) => { - if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.') - if (action === 'setup') { - const secrets = await vault.load() - return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey), - deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint } - } - if (action === 'identity.save') { - const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint) - if (!previous.deviceId || previous.deviceId !== params.deviceId || - (previous.localFingerprint && previous.localFingerprint !== fingerprint)) - throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.') - await vault.save({ ...previous, localFingerprint: fingerprint }) - return { saved: true } - } - if (action === 'account.save') { - if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.') - if (clients.size) throw new Error('Hesap değiştirmeden önce mobil bağlantıyı kapatın.') - const previous = await vault.load() - if (previous.accountToken && previous.accountToken !== params.token) throw new Error('Hesabı değiştirmek için önce mevcut cihazı iptal edin ve bağlantı penceresini yeniden açın.') - await vault.save({ ...previous, accountToken: params.token }) - return { saved: true } - } - if (action === 'account.login.start') { - const previous = await vault.load() - if (previous.accountToken || clients.size) throw new Error('Kayıtlı hesap var. Önce mevcut cihazı iptal edin.') - const login = await new AccountClient({}).call('/v1/aiturk/mobile-auth/start', {}) - if (!/^https:\/\/turkservis\.online\/account\/aiturk\/mobile-login\/[0-9a-f-]{36}$/.test(login.verificationUri)) throw new Error('Geçersiz giriş adresi.') - await vault.save({ ...previous, pendingLogin: login }) - await shell.openExternal(login.verificationUri) - return { code: login.userCode } - } - 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 } - delete previous.pendingLogin - await vault.save({ ...previous, accountToken: result.accountToken }) - return { approved: true } - } - if (action === 'account.logout') { - const previous = await vault.load(), token = previous.accountToken || previous.pendingLogin?.deviceCode - if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {}) - for (const [id, entry] of clients) { entry.client.close(); send({ channelId: id, close: true }) } - clients.clear(); await vault.save({}) - return { cleared: true } - } - if (action === 'desktop.pair') { - const result = await call('/v1/aiturk/pairings', { display_name: hostname(), platform: 'windows-x64' }) - await vault.save({ ...await vault.load(), deviceId: result.device_id, deviceToken: result.device_token, - pairingId: result.pairing_id }) - await shell.openExternal('https://turkservis.online' + result.verification_uri) - return { code: result.user_code, expiresAt: result.expires_at } - } - if (action === 'phone.approve') { - if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.') - const identity = parsePhoneIdentity(params.identity, params.pairingId) - const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId] - if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.') - const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code }) - if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.') - const current = await vault.load() - if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.') - await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } }) - return result - } - 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 }) - for (const [id, entry] of clients) if (entry.phoneId === params.deviceId || result.self_revoked) { - entry.client.close(); clients.delete(id); send({ channelId: id, close: true }) - } - if (result.self_revoked) await vault.save({}) - return result - } - if (action === 'inbox') { - const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load() - return { ...data, channels: data.channels.map(offer => { - const fingerprint = secrets.phoneFingerprints?.[offer.phone_id] - assertRemoteIdentity(offer.sdp, fingerprint) - return { ...offer, trustedPhoneFingerprint: fingerprint } - }) } - } - if (action === 'ice') return call('/v1/aiturk/mobile/ice') - if (action === 'channel.answer') { - const inbox = await call('/v1/aiturk/mobile/channels') - const offer = inbox.channels.find(row => row.id === params.channelId) - if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.') - assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id]) - const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {}) - const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }), - { deviceId: offer.phone_id, journal, authorize }) - await call(`/v1/aiturk/mobile/channels/${offer.id}/answer`, { sdp: params.sdp }) - clients.set(offer.id, { client, phoneId: offer.phone_id, authorize }) - return { accepted: true } - } - if (action === 'channel.heartbeat') { - const entry = clients.get(params.channelId) - if (!entry) throw new Error('Bağlantı yok.') - return entry.authorize() - } - if (action === 'channel.close') { - clients.get(params.channelId)?.client.close(); clients.delete(params.channelId) - return { closed: true } - } - if (action === 'channel.request') { - const entry = clients.get(params.channelId) - if (!entry) throw new Error('Bağlantı yok.') - await entry.authorize() - const message = params.message - if (!message || JSON.stringify(message).length > 65000) throw new Error('İstek boyutu geçersiz.') - if (message.action === 'google.key.save') { - const key = message.params?.key - if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{20,256}$/.test(key)) throw new Error('Google anahtarı biçimi geçersiz.') - await vault.save({ ...await vault.load(), googleKey: key }) - return { saved: true } - } - if (message.action === 'google.key.delete') { - const secrets = await vault.load(); delete secrets.googleKey; await vault.save(secrets); return { deleted: true } - } - if (message.action === 'voice.token') return issuer.issue(entry.phoneId) - return entry.client.handle(message) - } - throw new Error('Desteklenmeyen mobil işlem.') + const actions = { + state: () => link.state(), + enable: () => link.enable(), + disable: () => link.disable(), + 'pair.begin': () => link.pairBegin(), + 'pair.cancel': () => link.pairCancel(), + devices: () => link.devices(), + 'device.revoke': params => link.deviceRevoke(params?.deviceId), + 'firewall.retry': () => link.firewallRetry(), + 'firewall.remove': () => link.firewallRemove(), } + const handler = async (event, action, params) => { + if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('aiturk-mobile:forbidden') + if (typeof action !== 'string' || !Object.hasOwn(actions, action)) throw new Error('aiturk-mobile:unsupported') + try { return await actions[action](params) } catch (error) { + // Only fixed codes cross to the renderer; never upstream error text. + throw new Error(CODED.test(error?.message) ? error.message : 'aiturk-mobile:failed') + } + } + const unsubscribe = link.onEvent(event => { if (!window.isDestroyed()) window.webContents.send('aiturk-mobile-event', event) }) ipcMain.handle('aiturk-mobile', handler) - window.on('closed', () => { for (const entry of clients.values()) entry.client.close(); clients.clear(); ipcMain.removeHandler('aiturk-mobile') }) + window.on('closed', () => { + unsubscribe(); ipcMain.removeHandler('aiturk-mobile') + // The code on screen is gone with the window; do not leave pairing open behind it. + void link.pairCancel().catch(() => {}) + }) void window.loadFile(join(assets, 'mobile-host.html')) return window } diff --git a/apps/desktop/electron/mobile-lan-cert.test.ts b/apps/desktop/electron/mobile-lan-cert.test.ts new file mode 100644 index 0000000..4cca540 --- /dev/null +++ b/apps/desktop/electron/mobile-lan-cert.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed + * certificate of the "Telefon bağlantısı" LAN proxy and its vault storage. + */ + +import crypto from 'node:crypto' + +import { describe, expect, it } from 'vitest' + +import { + createSelfSignedCertificate, + fingerprintOfPem, + isUsableIdentity, + loadOrCreateLanIdentity +} from './mobile-lan-cert' + +function memoryVault(initial: Record = {}) { + const state = { value: structuredClone(initial), saves: 0 } + + return { + state, + load: async () => structuredClone(state.value), + save: async (value: Record) => { + state.value = structuredClone(value) + state.saves++ + } + } +} + +describe('createSelfSignedCertificate', () => { + it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => { + const identity = createSelfSignedCertificate() + const x509 = new crypto.X509Certificate(identity.cert) + + expect(x509.verify(x509.publicKey)).toBe(true) + expect(x509.publicKey.asymmetricKeyType).toBe('ec') + expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1') + expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true) + expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3) + expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now()) + }) + + it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => { + const identity = createSelfSignedCertificate() + const der = new crypto.X509Certificate(identity.cert).raw + + expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url')) + expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint) + expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint) + }) + + it('refuses a validity that UTCTime cannot carry', () => { + expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow( + 'mobile-lan-cert:validity-out-of-range' + ) + }) +}) + +describe('loadOrCreateLanIdentity', () => { + it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => { + const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } }) + const first = await loadOrCreateLanIdentity(vault) + + expect(first.created).toBe(true) + expect(vault.state.value.accountToken).toBe('old-field') + expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' }) + expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY') + + const second = await loadOrCreateLanIdentity(vault) + + expect(second.created).toBe(false) + expect(second.fingerprint).toBe(first.fingerprint) + expect(vault.state.saves).toBe(1) + }) + + it('replaces a corrupt, mismatched or nearly expired identity', async () => { + const good = createSelfSignedCertificate() + const other = createSelfSignedCertificate() + + expect(isUsableIdentity(good)).toBe(true) + expect(isUsableIdentity({ ...good, key: other.key })).toBe(false) + expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false) + expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false) + expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false) + + const vault = memoryVault({ lanTls: { ...good, key: other.key } }) + const renewed = await loadOrCreateLanIdentity(vault) + + expect(renewed.created).toBe(true) + expect(renewed.fingerprint).not.toBe(good.fingerprint) + }) + + it('surfaces a vault failure instead of running without a stored key', async () => { + const vault = { + load: async () => { + throw new Error('vault closed') + }, + save: async () => undefined + } + + await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed') + }) +}) diff --git a/apps/desktop/electron/mobile-lan-cert.ts b/apps/desktop/electron/mobile-lan-cert.ts new file mode 100644 index 0000000..d4919c4 --- /dev/null +++ b/apps/desktop/electron/mobile-lan-cert.ts @@ -0,0 +1,159 @@ +/** + * mobile-lan-cert.ts + * + * Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy + * (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built + * by hand and signed with node:crypto. + * + * The phone never trusts a CA for this server. It pins the certificate by + * `fp` = SHA-256 of the certificate's DER bytes, base64url without padding + * (43 characters), which it learns from the pairing QR / rendezvous record. + * + * The private key lives only in the injected vault (MobileVault → Electron + * safeStorage → Windows DPAPI). Existing vault fields are preserved. + */ + +import crypto from 'node:crypto' + +export const MOBILE_LAN_CERT_DAYS = 3650 +/** Regenerate when less than this is left; the phone must then pair again. */ +const RENEW_BEFORE_MS = 30 * 24 * 3600e3 +const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/ + +export interface MobileLanIdentity { + /** PEM certificate. */ + cert: string + /** PEM PKCS#8 private key. Never leaves the main process. */ + key: string + /** SHA-256 of the DER certificate, base64url, 43 characters. */ + fingerprint: string +} + +export interface MobileLanVault { + load: () => Promise> + save: (value: Record) => Promise +} + +function der(tag: number, ...parts: Buffer[]): Buffer { + const body = Buffer.concat(parts) + let length: Buffer + + if (body.length < 128) { + length = Buffer.from([body.length]) + } else { + const bytes: number[] = [] + + for (let n = body.length; n > 0; n >>= 8) { + bytes.unshift(n & 255) + } + + length = Buffer.from([0x80 | bytes.length, ...bytes]) + } + + return Buffer.concat([Buffer.from([tag]), length, body]) +} + +const seq = (...parts: Buffer[]) => der(0x30, ...parts) +const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex')) +const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value) + +const utcTime = (date: Date) => + der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii')) + +const ECDSA_SHA256 = seq(oid('2a8648ce3d040302')) +const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8'))))) + +export function fingerprintOfDer(certDer: Uint8Array): string { + return crypto.createHash('sha256').update(certDer).digest('base64url') +} + +/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */ +export function fingerprintOfPem(certPem: string): string { + return fingerprintOfDer(new crypto.X509Certificate(certPem).raw) +} + +export function createSelfSignedCertificate( + options: { commonName?: string; days?: number; now?: Date } = {} +): MobileLanIdentity { + const commonName = options.commonName ?? 'AITURK IDE' + const days = options.days ?? MOBILE_LAN_CERT_DAYS + const now = options.now ?? new Date() + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + const serial = crypto.randomBytes(16) + + serial[0] = (serial[0] & 0x7f) | 0x01 + + const notBefore = new Date(now.getTime() - 24 * 3600e3) + const notAfter = new Date(now.getTime() + days * 24 * 3600e3) + + if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) { + // UTCTime cannot carry these years; refuse rather than emit a wrong date. + throw new Error('mobile-lan-cert:validity-out-of-range') + } + + const tbs = seq( + der(0xa0, int(Buffer.from([2]))), // v3 + int(serial), + ECDSA_SHA256, + name(commonName), + seq(utcTime(notBefore), utcTime(notAfter)), + name(commonName), + publicKey.export({ type: 'spki', format: 'der' }) as Buffer + ) + + const signature = crypto.sign('sha256', tbs, privateKey) + const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature)) + const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n') + + return { + cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`, + key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, + fingerprint: fingerprintOfDer(certDer) + } +} + +/** True when the stored pair is a usable, unexpired, matching identity. */ +export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity { + const row = value as MobileLanIdentity | null + + if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) { + return false + } + + try { + const x509 = new crypto.X509Certificate(row.cert) + + return ( + fingerprintOfDer(x509.raw) === row.fingerprint && + x509.checkPrivateKey(crypto.createPrivateKey(row.key)) && + new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS + ) + } catch { + return false + } +} + +/** + * Load the LAN identity from the vault, or create and store one. Stored under + * the vault's `lanTls` field; every other field is written back untouched. + * `created` tells the caller that previously paired phones no longer match. + */ +export async function loadOrCreateLanIdentity( + vault: MobileLanVault, + options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {} +): Promise { + const now = options.now ? options.now() : new Date() + const secrets = (await vault.load()) ?? {} + + if (isUsableIdentity(secrets.lanTls, now)) { + const { cert, key, fingerprint } = secrets.lanTls + + return { cert, key, fingerprint, created: false } + } + + const identity = (options.create ?? createSelfSignedCertificate)({ now }) + + await vault.save({ ...secrets, lanTls: identity }) + + return { ...identity, created: true } +} diff --git a/apps/desktop/electron/mobile-lan-proxy.test.ts b/apps/desktop/electron/mobile-lan-proxy.test.ts new file mode 100644 index 0000000..b7476d1 --- /dev/null +++ b/apps/desktop/electron/mobile-lan-proxy.test.ts @@ -0,0 +1,1148 @@ +/** + * Tests for electron/mobile-lan-proxy.ts — the "Telefon bağlantısı" LAN proxy, + * pairing session and controller (contract: aiturk-mobil-eslestirme-spec.md §5). + * + * Real TLS sockets on 127.0.0.1 only, against a fake backend that mimics the + * dashboard's loopback rules (Host check, session token, token in the root + * page). No real backend, gateway, firewall or LAN interface is touched. + */ + +import crypto from 'node:crypto' +import http from 'node:http' +import https from 'node:https' +import net from 'node:net' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { WebSocket, WebSocketServer } from 'ws' + +import { createSelfSignedCertificate } from './mobile-lan-cert' +import { + buildPairQrPayload, + createLanProxy, + createMobileLink, + createWindowsFirewall, + firewallCommand, + formatPairCode, + generatePairCode, + isPhoneForbidden, + isPrivateIpv4, + MobileLinkError, + type MobileLinkEvent, + normalizePairCode, + PAIR_CODE_ALPHABET, + pairLookup, + parseMobileLinkStored, + selectLanAddresses +} from './mobile-lan-proxy' + +const SESSION = crypto.randomBytes(32).toString('base64url') +const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']) +const hostOnly = (value: unknown) => String(value || '').replace(/:\d+$/, '').toLowerCase() +const seen: { path: string; host: string; raw: string; method: string }[] = [] +const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ') + +// ── fake backend: host check + session auth + token in the root page ──────── +let backend: http.Server +let backendPort = 0 +let wss: WebSocketServer +let resolveCalls = 0 + +const resolveBackend = async () => { + resolveCalls++ + + return { baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION } +} + +beforeAll(async () => { + backend = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://x') + + const send = (status: number, body: string, type = 'application/json') => { + res.writeHead(status, { 'content-type': type, 'set-cookie': 'x=1' }) + res.end(body) + } + + if (!LOOPBACK.has(hostOnly(req.headers.host))) { + return send(400, '{"detail":"Invalid Host header"}') + } + + if (url.pathname === '/') { + return send(200, ``, 'text/html') + } + + if (url.pathname.startsWith('/api/') && url.pathname !== '/api/status') { + const ok = + req.headers['x-hermes-session-token'] === SESSION || + req.headers.authorization === `Bearer ${SESSION}` || + (url.pathname === '/api/files/download' && url.searchParams.get('token') === SESSION) + + if (!ok) { + return send(401, '{"detail":"Unauthorized"}') + } + } + + seen.push({ path: req.url ?? '', host: String(req.headers.host), method: String(req.method), raw: JSON.stringify(req.headers) + req.url }) + send(200, JSON.stringify({ ok: true, path: url.pathname })) + }) + + wss = new WebSocketServer({ noServer: true }) + backend.on('upgrade', (req, socket, head) => { + const url = new URL(req.url ?? '/', 'http://x') + const origin = req.headers.origin + + const allowed = + url.searchParams.get('token') === SESSION && + LOOPBACK.has(hostOnly(req.headers.host)) && + !req.headers.cookie && + (!origin || LOOPBACK.has(new URL(origin).hostname)) + + // The real backend also accepts /api/pty with a valid token: the block must come from the proxy. + if (!allowed || !['/api/ws', '/api/pty', '/api/console', '/api/plugins/kanban/events'].includes(url.pathname)) { + socket.end('HTTP/1.1 403 Forbidden\r\n\r\n') + + return + } + + wss.handleUpgrade(req, socket, head, ws => + ws.on('message', data => { + const frame = JSON.parse(String(data)) + + if (frame.method === 'gateway.ping') { + ws.send(JSON.stringify({ jsonrpc: '2.0', result: { ok: true }, id: frame.id })) + } + }) + ) + }) + await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) + backendPort = (backend.address() as net.AddressInfo).port +}) + +afterAll(async () => { + wss.close() + backend.closeAllConnections() + await new Promise(resolve => backend.close(resolve)) +}) + +// ── client helpers (what the phone does: trust ONLY the pinned certificate) ─ +interface CallOptions { + token?: string + headerToken?: string + body?: unknown + rawBody?: string + ca?: string + headers?: Record +} + +function call(port: number, method: string, path: string, options: CallOptions = {}) { + return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>((resolve, reject) => { + const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) + + const req = https.request( + { + host: '127.0.0.1', + port, + method, + path, + agent: false, + ...(options.ca ? { ca: options.ca } : {}), + checkServerIdentity: () => undefined, + headers: { + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), + ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), + ...(payload ? { 'content-type': 'application/json' } : {}), + origin: 'http://evil.example', + cookie: 'session=evil', + ...options.headers + } + }, + res => { + let text = '' + + res.on('data', chunk => (text += chunk)) + res.on('end', () => { + let json: any = null + + try { + json = JSON.parse(text) + } catch { + json = null + } + + resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json }) + }) + } + ) + + req.on('error', reject) + req.end(payload) + }) +} + +function wsOpen(url: string, ca: string, extra: Record = {}) { + return new Promise<{ open: boolean; ws?: WebSocket; status?: number; error?: string }>(resolve => { + const ws = new WebSocket(url, { ca, checkServerIdentity: () => undefined, ...extra } as any) + + ws.once('open', () => resolve({ ws, open: true })) + ws.once('unexpected-response', (_req, res) => { + resolve({ open: false, status: res.statusCode }) + res.resume() + }) + ws.once('error', error => resolve({ open: false, error: error.message })) + }) +} + +const connectOutcome = (port: number) => + new Promise(resolve => { + const socket = net.connect(port, '127.0.0.1') + + socket.once('connect', () => { + socket.destroy() + resolve('open') + }) + socket.once('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) + }) + +async function until(check: () => boolean, ms = 3000) { + const end = Date.now() + ms + + while (!check()) { + if (Date.now() > end) { + throw new Error('condition not reached in time') + } + + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +// ── proxy fixture ─────────────────────────────────────────────────────────── +async function startProxy(over: Record = {}) { + const identity = createSelfSignedCertificate() + const clock = { now: 1_800_000_000_000 } + const logs: string[] = [] + const saved: any[] = [] + const ended: string[] = [] + + const proxy = createLanProxy({ + tls: identity, + resolveBackend, + version: '1.0.0-test', + pcName: 'TEST-PC', + now: () => clock.now, + log: line => logs.push(line), + onDevicesChanged: devices => { + saved.push(structuredClone(devices)) + }, + onPairingEnded: reason => { + ended.push(reason) + }, + ...over + } as any) + + const port = await proxy.listen(0, '127.0.0.1') + + const pair = async (name = 'Pixel 9') => { + const { code } = proxy.beginPairing() + const r = await call(port, 'POST', '/aiturk/pair/claim', { ca: identity.cert, body: { code, device_name: name } }) + + expect(r.status).toBe(200) + + return { code, token: r.json.token as string, deviceId: r.json.device_id as string, response: r } + } + + return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert } +} + +describe('LAN proxy: pairing (hello / claim)', () => { + it('hello is unauthenticated and reports whether pairing is open', async () => { + const t = await startProxy() + + expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json).toEqual({ + app: 'aiturk-ide', + version: '1.0.0-test', + pairing: false + }) + t.proxy.beginPairing() + expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json.pairing).toBe(true) + await t.proxy.stop() + }) + + it('claim is 410 pairing_closed while pairing is not open', async () => { + const t = await startProxy() + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: 'AAAAAAAA', device_name: 'x' } }) + + expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) + await t.proxy.stop() + }) + + it('four wrong codes are 401 wrong_code; the right code (lower case, dashed) then yields a device token', async () => { + const t = await startProxy() + const { code } = t.proxy.beginPairing() + + for (let i = 0; i < 4; i++) { + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) + + expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }]) + } + + const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} ` + const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: typed, device_name: ' Pixel\u0000 9 ' } }) + + expect(ok.status).toBe(200) + expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version']) + expect(ok.json.token).toMatch(/^amd_[A-Za-z0-9_-]{43}$/) + expect(ok.json.device_id).toMatch(/^[0-9a-f-]{36}$/) + expect(ok.json.pc_name).toBe('TEST-PC') + expect(ok.json.version).toBe('1.0.0-test') + expect(ok.headers['cache-control']).toBe('no-store') + expect(t.proxy.devices()[0].name).toBe('Pixel 9') + // Only the digest is handed to the store. + expect(t.saved.at(-1)[0].tokenHash).toBe(crypto.createHash('sha256').update(ok.json.token).digest('hex')) + expect(JSON.stringify(t.saved)).not.toContain('amd_') + expect(t.ended).toEqual(['claimed']) + await t.proxy.stop() + }) + + it('the same code is 410 on second use (consumed before the token is minted)', async () => { + const t = await startProxy() + const { code } = await t.pair() + const again = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) + + expect([again.status, again.json]).toEqual([410, { error: 'pairing_closed' }]) + expect(t.proxy.devices()).toHaveLength(1) + await t.proxy.stop() + }) + + it('the fifth wrong attempt locks pairing; the right code is then 410 too', async () => { + const t = await startProxy() + const { code } = t.proxy.beginPairing() + + for (let i = 0; i < 4; i++) { + expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } })).status).toBe(401) + } + + const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) + + expect([fifth.status, fifth.json]).toEqual([410, { error: 'pairing_locked' }]) + + const right = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) + + expect([right.status, right.json]).toEqual([410, { error: 'pairing_closed' }]) + expect(t.proxy.devices()).toHaveLength(0) + expect(t.ended).toEqual(['locked']) + await t.proxy.stop() + }) + + it('an expired code is 410 pairing_closed', async () => { + const t = await startProxy() + const { code, expiresAt } = t.proxy.beginPairing() + + expect(expiresAt - t.clock.now).toBe(5 * 60_000) + t.clock.now += 5 * 60_000 + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) + + expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) + expect(t.ended).toEqual(['expired']) + await t.proxy.stop() + }) + + it('malformed bodies are 400 invalid_request and do not burn an attempt; > 4 KiB is 413', async () => { + const t = await startProxy() + const { code } = t.proxy.beginPairing() + + for (const rawBody of ['{not json', '[]', '{"code":12345678,"device_name":"x"}', `{"code":"${code}"}`, `{"code":"${code}","device_name":""}`, JSON.stringify({ code, device_name: 'n'.repeat(61) })]) { + const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody }) + + expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }]) + } + + const big = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) }) + + expect(big.status).toBe(413) + expect((await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status).toBe(200) + await t.proxy.stop() + }) +}) + +describe('LAN proxy: REST', () => { + it('root and everything outside /api/ is 404 and never carries the backend session token', async () => { + const t = await startProxy() + const { token } = await t.pair() + + for (const path of ['/', '/index.html', '/api', '/assets/app.js', '/aiturk/pair/other', '/?token=' + token]) { + const r = await call(t.port, 'GET', path, { ca: t.ca, token }) + + expect([path, r.status]).toEqual([path, 404]) + expect(r.text).not.toContain(SESSION) + expect(r.json).toEqual({ error: 'not_found' }) + } + + await t.proxy.stop() + }) + + it('no token, a wrong token and the backend’s REAL token are all 401 — and never resolve (start) a backend', async () => { + const t = await startProxy() + + await t.pair() + resolveCalls = 0 + + const attempts: [string, CallOptions][] = [ + ['/api/sessions', {}], + ['/api/status', {}], // public on the backend, still closed here + ['/api/sessions', { token: 'amd_wrong' }], + ['/api/sessions', { token: `amd_${'A'.repeat(43)}` }], + ['/api/sessions', { token: SESSION }], + ['/api/sessions', { headerToken: SESSION }], + [`/api/files/download?token=${SESSION}`, {}], + ['/api/env', { token: SESSION }] + ] + + for (const [path, options] of attempts) { + const r = await call(t.port, 'GET', path, { ca: t.ca, ...options }) + + expect([path, r.status, r.json]).toEqual([path, 401, { error: 'unauthorized' }]) + } + + expect(resolveCalls).toBe(0) + await t.proxy.stop() + }) + + it('a valid device token is forwarded with the real session token; the backend never sees the device token, Origin or Cookie', async () => { + const t = await startProxy() + const { token } = await t.pair() + + for (const options of [{ token }, { headerToken: token }]) { + seen.length = 0 + const r = await call(t.port, 'GET', '/api/sessions?limit=20', { ca: t.ca, ...options, headers: { 'x-forwarded-for': '10.0.0.9' } }) + + expect(r.status).toBe(200) + expect(r.json).toEqual({ ok: true, path: '/api/sessions' }) + expect(seen).toHaveLength(1) + expect(seen[0].host).toBe(`127.0.0.1:${backendPort}`) + expect(seen[0].path).toBe('/api/sessions?limit=20') + expect(seen[0].raw).toContain(SESSION) + expect(seen[0].raw).not.toContain('amd_') + expect(seen[0].raw).not.toContain('evil') + expect(seen[0].raw).not.toContain('10.0.0.9') + expect(r.headers['set-cookie']).toBeUndefined() + expect(r.text).not.toContain(SESSION) + } + + const post = await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { title: 'x' } }) + + expect(post.status).toBe(200) + expect(seen.at(-1)?.method).toBe('POST') + await t.proxy.stop() + }) + + it('?token= is swapped for the backend token (download link form)', async () => { + const t = await startProxy() + const { token } = await t.pair() + + seen.length = 0 + const r = await call(t.port, 'GET', `/api/files/download?path=a.png&token=${token}`, { ca: t.ca }) + + expect(r.status).toBe(200) + expect(seen[0].path).toContain(`token=${SESSION}`) + expect(seen[0].path).not.toContain('amd_') + await t.proxy.stop() + }) + + it('forbidden endpoints are 403 before the backend is touched; allowed media and reads are 200', async () => { + const t = await startProxy() + const { token } = await t.pair() + + const forbidden: [string, string][] = [ + ['GET', '/api/env'], + ['PUT', '/api/env'], + ['POST', '/api/env/reveal'], + ['GET', '/api/config/raw'], + ['PUT', '/api/config/raw'], + ['POST', '/api/ops/doctor'], + ['GET', '/api/ops/backup/download?archive=x'], + ['POST', '/api/hermes/update'], + ['GET', '/api/hermes/update/check'], + ['GET', '/api/credentials/pool'], + ['POST', '/api/credentials/pool'], + ['POST', '/api/providers/custom-endpoints'], + ['DELETE', '/api/providers/oauth/x'], + ['POST', '/api/mcp/servers'], + ['POST', '/api/mcp/catalog/install'], + ['POST', '/api/webhooks'], + ['PUT', '/api/config'], + ['POST', '/api/config/model'], + ['DELETE', '/api/webhooks/x'], + ['POST', '/api/dashboard/agent-plugins/install'], + ['DELETE', '/api/dashboard/agent-plugins/x'], + ['GET', '/api/files'], + ['DELETE', '/api/files?path=x'], + ['GET', '/api/files/read?path=x'], + ['POST', '/api/files/upload'], + ['POST', '/api/files/download'], + // encodings the backend would decode back to a forbidden route + ['GET', '/api/%65nv'], + ['GET', '/api/ENV'], + ['GET', '/api/files%2Fread?path=x'], + ['GET', '/api/sessions/../env'] + ] + + seen.length = 0 + resolveCalls = 0 + + for (const [method, path] of forbidden) { + const r = await call(t.port, method, path, { ca: t.ca, token, ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) }) + + expect([method, path, r.status, r.json]).toEqual([method, path, 403, { error: 'forbidden' }]) + } + + expect(seen).toHaveLength(0) + expect(resolveCalls).toBe(0) + + for (const path of ['/api/%2e%2e%2fapi/env/x%00', '/api//env', '/api/a%5Cb', '/api/x%2525']) { + expect([path, (await call(t.port, 'GET', path, { ca: t.ca, token })).status]).toEqual([path, 400]) + } + + const allowed: [string, string][] = [ + ['GET', '/api/files/download?path=x'], + ['GET', '/api/files/stream?path=x'], + ['GET', '/api/providers/custom-endpoints'], + ['GET', '/api/mcp/servers'], + ['GET', '/api/webhooks'], + ['GET', '/api/config'], + ['GET', '/api/sessions'] + ] + + for (const [method, path] of allowed) { + expect([path, (await call(t.port, method, path, { ca: t.ca, token })).status]).toEqual([path, 200]) + } + + await t.proxy.stop() + }) + + it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => { + const remote = await startProxy({ + resolveBackend: async () => { + throw new Error(`remote gateway secret ${SESSION}`) + } + }) + + const a = await remote.pair() + const r = await call(remote.port, 'GET', '/api/sessions', { ca: remote.ca, token: a.token }) + + expect([r.status, r.json]).toEqual([503, { error: 'backend_unavailable' }]) + expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503) + await remote.proxy.stop() + + const elsewhere = await startProxy({ resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) }) + const b = await elsewhere.pair() + + expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503) + await elsewhere.proxy.stop() + }) +}) + +describe('LAN proxy: WebSocket', () => { + it('no token / wrong token / backend token are 401; /api/pty and /api/console are 403 even with a valid token', async () => { + const t = await startProxy() + const { token } = await t.pair() + const base = `wss://127.0.0.1:${t.port}` + + resolveCalls = 0 + expect((await wsOpen(`${base}/api/ws`, t.ca)).status).toBe(401) + expect((await wsOpen(`${base}/api/ws?token=amd_wrong`, t.ca)).status).toBe(401) + expect((await wsOpen(`${base}/api/ws?token=${SESSION}`, t.ca)).status).toBe(401) + expect((await wsOpen(`${base}/api/pty`, t.ca)).status).toBe(401) + expect((await wsOpen(`${base}/api/pty?token=${token}`, t.ca)).status).toBe(403) + expect((await wsOpen(`${base}/api/console?token=${token}`, t.ca)).status).toBe(403) + expect((await wsOpen(`${base}/api/ws/extra?token=${token}`, t.ca)).status).toBe(403) + expect((await wsOpen(`${base}/api/%70ty?token=${token}`, t.ca)).status).toBe(403) + expect((await wsOpen(`${base}/?token=${token}`, t.ca)).status).toBe(404) + expect(resolveCalls).toBe(0) + await t.proxy.stop() + }) + + it('/api/ws and the kanban events path open with the device token (Origin and Cookie dropped) and carry frames', async () => { + const t = await startProxy() + const { token, deviceId } = await t.pair() + + for (const path of ['/api/ws', '/api/plugins/kanban/events']) { + const r = await wsOpen(`wss://127.0.0.1:${t.port}${path}?token=${token}`, t.ca, { + origin: 'http://evil.example', + headers: { cookie: 'session=evil' } + }) + + expect([path, r.open]).toEqual([path, true]) + + const answer = await new Promise(resolve => { + r.ws?.once('message', data => resolve(JSON.parse(String(data)))) + r.ws?.send(JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'gateway.ping' })) + }) + + expect(answer).toEqual({ jsonrpc: '2.0', result: { ok: true }, id: 7 }) + expect(t.proxy.devices().find(device => device.id === deviceId)?.live).toBe(1) + + const closed = new Promise(resolve => r.ws?.once('close', resolve)) + + r.ws?.close() + await closed + await until(() => t.proxy.devices()[0].live === 0) + } + + await t.proxy.stop() + }) + + it('revoking a device closes its open WebSocket and turns its token 401 (REST and WS); other devices keep working', async () => { + const t = await startProxy() + const first = await t.pair('Telefon 1') + const second = await t.pair('Telefon 2') + const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca) + const other = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${second.token}`, t.ca) + + expect([live.open, other.open]).toEqual([true, true]) + + const closed = new Promise(resolve => live.ws?.once('close', resolve)) + + expect(await t.proxy.revoke(first.deviceId)).toBe(true) + await closed + expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: first.token })).status).toBe(401) + expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca)).status).toBe(401) + expect(t.proxy.devices().map(device => device.name)).toEqual(['Telefon 2']) + expect(t.saved.at(-1)).toHaveLength(1) + expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: second.token })).status).toBe(200) + expect(other.ws?.readyState).toBe(WebSocket.OPEN) + expect(await t.proxy.revoke(first.deviceId)).toBe(false) + await t.proxy.stop() + }) +}) + +describe('LAN proxy: TLS, listening and logs', () => { + it('serves exactly the pinned certificate; a client using the system CAs rejects it; plain HTTP does not work', async () => { + const t = await startProxy() + + const peer = await new Promise((resolve, reject) => { + const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, res => { + resolve(crypto.createHash('sha256').update((res.socket as any).getPeerCertificate(true).raw).digest('base64url')) + res.resume() + }) + + req.on('error', reject) + req.end() + }) + + expect(peer).toBe(t.identity.fingerprint) + expect(peer).not.toBe(createSelfSignedCertificate().fingerprint) + + // A phone pinned to ANOTHER certificate must refuse this server. + await expect(call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert })).rejects.toThrow() + + const systemCa = await new Promise(resolve => { + const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => resolve('accepted')) + + req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) + req.end() + }) + + expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/) + + const plain = await new Promise(resolve => { + const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => resolve(String(res.statusCode))) + + req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error'))) + req.end() + }) + + expect(plain).not.toBe('200') + await t.proxy.stop() + }) + + it('after stop() nothing listens (ECONNREFUSED) and open sockets are closed', async () => { + const t = await startProxy() + const { token } = await t.pair() + const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca) + const closed = new Promise(resolve => live.ws?.once('close', resolve)) + + expect(await connectOutcome(t.port)).toBe('open') + await t.proxy.stop() + await closed + expect(await connectOutcome(t.port)).toBe('ECONNREFUSED') + expect(t.proxy.listening).toBe(false) + }) + + it('logs method + path + status only: no device token, pairing code, session token, query or body', async () => { + const t = await startProxy() + const { code, token } = await t.pair('Gizli Telefon') + + await call(t.port, 'GET', `/api/sessions?token=${token}&q=sorgu-metni`, { ca: t.ca }) + await call(t.port, 'GET', `/api/sessions/${token}`, { ca: t.ca, token }) + await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { secret: 'govde-metni' } }) + await call(t.port, 'GET', '/api/env', { ca: t.ca, token }) + await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: SESSION }) + await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) + await wsOpen(`wss://127.0.0.1:${t.port}/api/pty?token=${token}`, t.ca) + + const text = t.logs.join('\n') + + expect(t.logs).toContain('POST /aiturk/pair/claim 200') + expect(t.logs).toContain('GET /api/sessions 200') + expect(t.logs).toContain('GET /api/env 403') + expect(t.logs).toContain('GET /api/sessions 401') + expect(t.logs).toContain('GET /api/pty 403') + expect(text).not.toContain('amd_') + expect(text).not.toContain(token.slice(4)) + expect(text).not.toContain(code) + expect(text).not.toContain(SESSION) + expect(text).not.toContain('sorgu-metni') + expect(text).not.toContain('govde-metni') + expect(text).not.toContain('Gizli Telefon') + await t.proxy.stop() + }) +}) + +// ── controller ────────────────────────────────────────────────────────────── +function linkHarness(over: Record = {}) { + const vault = { value: { accountToken: 'eski-alan' } as Record } + const store = { value: null as any, saves: 0 } + const firewallCalls: string[] = [] + const firewallResults: any[] = over.firewallResults ?? [] + const fetchCalls: { url: string; init: any }[] = [] + const events: MobileLinkEvent[] = [] + const logs: string[] = [] + const state = { remote: false, offerStatus: over.offerStatus ?? 201 } + + const deps = { + vault: { + load: async () => structuredClone(vault.value), + save: async (value: Record) => { + vault.value = structuredClone(value) + } + }, + store: { + load: async () => structuredClone(store.value), + save: async (value: any) => { + store.value = structuredClone(value) + store.saves++ + } + }, + resolveBackend, + isRemoteMode: () => state.remote, + firewall: { + addRule: async (port: number) => { + firewallCalls.push(`add:${port}`) + + return firewallResults.shift() ?? { ok: true } + }, + removeRule: async () => { + firewallCalls.push('remove') + + return { ok: true } + } + }, + fetch: async (url: string, init: any) => { + fetchCalls.push({ url, init }) + + if (state.offerStatus === 'throw') { + throw new Error('network down') + } + + if (url.endsWith('/offers')) { + return { + status: state.offerStatus, + json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 }) + } + } + + return { status: 200, json: async () => ({ cancelled: true }) } + }, + addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'], + version: '1.0.0-test', + pcName: 'TEST-PC', + platform: 'win32', + listenHost: '127.0.0.1', + ports: [0], + log: (line: string) => logs.push(line) + } + + const make = () => { + const link = createMobileLink(deps as any) + + link.onEvent(event => events.push(event)) + + return link + } + + return { link: make(), make, vault, store, firewallCalls, fetchCalls, events, logs, state } +} + +describe('mobile link controller', () => { + it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => { + const h = linkHarness() + + expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, port: null, pairing: null, devices: [] }) + + const enabled = await h.link.enable() + const port = enabled.port as number + + expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'ok', remote: false }) + expect(h.firewallCalls).toEqual([`add:${port}`]) + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + + const pairing = await h.link.pairBegin() + + expect(pairing.listening).toBe(true) + expect(await connectOutcome(port)).toBe('open') + + const cancelled = await h.link.pairCancel() + + expect(cancelled).toMatchObject({ listening: false, pairing: null }) + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + // The firewall is asked once per port, not on every enable. + await h.link.enable() + expect(h.firewallCalls).toEqual([`add:${port}`]) + await h.link.stop() + }) + + it('pairing: QR payload, rendezvous body without the raw code, claim over pinned TLS, "paired" event, cancel of the offer', async () => { + const h = linkHarness() + + await h.link.enable() + + const state = await h.link.pairBegin() + const pairing = state.pairing! + const port = state.port as number + const fp = h.vault.value.lanTls.fingerprint + + expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`)) + expect(pairing.qr).toBe(`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${pairing.code}`) + expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(pairing.codePath).toBe('pending') + + await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) + + const offer = h.fetchCalls[0] + const body = JSON.parse(offer.init.body) + + expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers') + expect(offer.init.method).toBe('POST') + expect(offer.init.redirect).toBe('error') + expect(offer.init.signal).toBeInstanceOf(AbortSignal) + expect(body).toEqual({ + lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'), + urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`], + fp + }) + expect(offer.init.body).not.toContain(pairing.code) + expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code) + + const ca = h.vault.value.lanTls.cert + const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: pairing.code, device_name: 'Pixel 9' } }) + + expect(claim.status).toBe(200) + await until(() => h.events.some(event => event.type === 'paired')) + + const paired = h.events.find(event => event.type === 'paired') as Extract + + expect(paired.name).toBe('Pixel 9') + expect(paired.state).toMatchObject({ listening: true, pairing: null }) + expect(paired.state.devices.map(device => device.name)).toEqual(['Pixel 9']) + + // Disk: digest only; the private key only in the vault; old vault fields kept. + const disk = JSON.stringify(h.store.value) + + expect(disk).not.toContain('amd_') + expect(disk).not.toContain(claim.json.token.slice(4)) + expect(disk).not.toContain('PRIVATE KEY') + expect(h.store.value.devices[0].tokenHash).toBe(crypto.createHash('sha256').update(claim.json.token).digest('hex')) + expect(h.vault.value.accountToken).toBe('eski-alan') + + // The gateway record is cancelled with its own token once the code is spent. + await until(() => h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))) + + const cancel = h.fetchCalls.find(entry => entry.url.endsWith('/cancel'))! + + expect(cancel.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/cancel') + expect(cancel.init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`) + expect(cancel.init.redirect).toBe('error') + + // Paired phone works; still listening after pairing ended. + expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) + + // Nothing secret reached the log or any event. + const text = h.logs.join('\n') + + expect(text).not.toContain('amd_') + expect(text).not.toContain(pairing.code) + expect(JSON.stringify(h.events.filter(event => event.type === 'paired'))).not.toContain(pairing.code) + + // Revoke through the controller: token dies, and with no phone left the socket closes. + const live = await wsOpen(`wss://127.0.0.1:${port}/api/ws?token=${claim.json.token}`, ca) + const closed = new Promise(resolve => live.ws?.once('close', resolve)) + const after = await h.link.deviceRevoke(claim.json.device_id) + + await closed + expect(after).toMatchObject({ devices: [], listening: false }) + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + await expect(h.link.deviceRevoke('not-a-uuid')).rejects.toThrow('aiturk-mobile:invalid_request') + await h.link.stop() + }) + + it('when the gateway is unreachable the QR path still works and the code path is marked unavailable', async () => { + for (const offerStatus of ['throw', 429, 409]) { + const h = linkHarness({ offerStatus }) + + await h.link.enable() + + const state = await h.link.pairBegin() + + await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) + + const claim = await call(state.port as number, 'POST', '/aiturk/pair/claim', { + ca: h.vault.value.lanTls.cert, + body: { code: state.pairing!.code, device_name: 'Pixel' } + }) + + expect(claim.status).toBe(200) + expect(h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))).toBe(false) + await h.link.stop() + } + }) + + it('disable stops listening and keeps the phones; the next app start resumes; quitting closes the socket', async () => { + const h = linkHarness() + + await h.link.enable() + + const state = await h.link.pairBegin() + const port = state.port as number + const ca = h.vault.value.lanTls.cert + const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) + + await until(() => h.events.some(event => event.type === 'paired')) + + const off = await h.link.disable() + + expect(off).toMatchObject({ enabled: false, listening: false }) + expect(off.devices).toHaveLength(1) + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + expect(h.firewallCalls).toEqual([`add:${port}`]) // disabling never removes the rule + await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled') + + // Disabled at app start: no socket. + const restartedOff = h.make() + + expect(await restartedOff.start()).toMatchObject({ enabled: false, listening: false }) + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + + await restartedOff.enable() + await restartedOff.stop() + + // Enabled with a paired phone at app start: listens on the stored port, same certificate, same token. + const restarted = h.make() + const resumed = await restarted.start() + + expect(resumed).toMatchObject({ enabled: true, listening: true, port, firewall: 'ok' }) + expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) + + await restarted.stop() + expect(await connectOutcome(port)).toBe('ECONNREFUSED') + }) + + it('remote-gateway mode refuses the feature and never listens', async () => { + const h = linkHarness() + + h.state.remote = true + await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError) + await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode') + expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true }) + expect(h.firewallCalls).toEqual([]) + + h.state.remote = false + await h.link.enable() + h.state.remote = true + await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:remote_mode') + expect((await h.link.start()).listening).toBe(false) + await h.link.stop() + }) + + it('a refused UAC prompt leaves a clear state and "Yeniden dene" adds the rule', async () => { + const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] }) + const state = await h.link.enable() + + expect(state).toMatchObject({ enabled: true, firewall: 'denied' }) + expect((await h.link.firewallRetry()).firewall).toBe('failed') + expect((await h.link.firewallRetry()).firewall).toBe('ok') + expect(h.firewallCalls).toHaveLength(3) + expect((await h.link.firewallRetry()).firewall).toBe('ok') + expect(h.firewallCalls).toHaveLength(3) + expect((await h.link.firewallRemove()).firewall).toBe('needed') + expect(h.firewallCalls.at(-1)).toBe('remove') + await h.link.stop() + }) + + it('without a usable LAN address pairing is refused; a failing vault is reported by code only', async () => { + const h = linkHarness({ addresses: ['8.8.8.8', '169.254.3.4'] }) + + await h.link.enable() + await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address') + await h.link.stop() + + const broken = createMobileLink({ + vault: { + load: async () => { + throw new Error(`Windows kasası ${SESSION}`) + }, + save: async () => undefined + }, + store: { load: async () => null, save: async () => undefined }, + resolveBackend, + isRemoteMode: () => false, + firewall: { addRule: async () => ({ ok: true }), removeRule: async () => ({ ok: true }) }, + fetch: async () => ({ status: 500, json: async () => ({}) }), + addresses: async () => [], + version: 'x', + pcName: 'x', + platform: 'win32', + listenHost: '127.0.0.1', + ports: [0] + } as any) + + await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/) + }) + + it('a regenerated certificate drops phones paired against the old one', async () => { + const h = linkHarness() + + await h.link.enable() + + const state = await h.link.pairBegin() + + await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) + await until(() => h.events.some(event => event.type === 'paired')) + await h.link.stop() + + delete h.vault.value.lanTls + + const restarted = h.make() + const resumed = await restarted.start() + + expect(resumed.devices).toEqual([]) + expect(resumed.listening).toBe(false) + await restarted.stop() + }) +}) + +describe('pure helpers', () => { + it('pairing code: alphabet, normalisation, display form, lookup digest', () => { + for (let i = 0; i < 200; i++) { + expect(generatePairCode()).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/) + } + + expect(generatePairCode(() => Buffer.from([0, 31, 32, 63, 255, 8, 9, 10]))).toBe('A9A99JKL') + expect(normalizePairCode(' abcd-efgh ')).toBe('ABCDEFGH') + expect(normalizePairCode(12345678)).toBe('') + expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH') + expect(pairLookup('abcd-efgh')).toBe(crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex')) + }) + + it('address candidates: private IPv4 only, default route first, virtual adapters dropped, at most four', () => { + expect(['10.1.2.3', '172.16.0.1', '172.31.255.254', '192.168.0.1'].every(isPrivateIpv4)).toBe(true) + expect(['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some(isPrivateIpv4)).toBe(false) + + const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }] + + const interfaces = { + 'vEthernet (WSL (Hyper-V firewall))': v4('172.28.0.1'), + 'VirtualBox Host-Only Network': v4('192.168.56.1'), + 'VMware Network Adapter VMnet8': v4('192.168.80.1'), + 'Loopback Pseudo-Interface 1': v4('127.0.0.1', true), + Ethernet: [{ address: 'fe80::1', family: 'IPv6', internal: false }, ...v4('10.0.0.5')], + 'Wi-Fi': v4('192.168.1.20'), + Mobile: v4('100.70.1.2'), + 'Ethernet 2': v4('192.168.2.2'), + 'Ethernet 3': v4('192.168.3.2'), + 'Ethernet 4': v4('192.168.4.2') + } + + expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual(['192.168.1.20', '10.0.0.5', '192.168.2.2', '192.168.3.2']) + expect(selectLanAddresses(interfaces, null)[0]).toBe('10.0.0.5') + expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([]) + expect(() => buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toThrow( + 'aiturk-mobile:no_lan_address' + ) + expect(buildPairQrPayload({ addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' })).toBe( + `aiturk://pair?v=1&u=10.0.0.1:47321&u=10.0.0.2:47321&u=10.0.0.3:47321&u=10.0.0.4:47321&fp=${'f'.repeat(43)}&c=ABCDEFGH` + ) + }) + + it('forbidden list', () => { + expect(isPhoneForbidden('GET', '/api/providers/oauth')).toBe(false) + expect(isPhoneForbidden('PATCH', '/api/providers/oauth')).toBe(true) + expect(isPhoneForbidden('HEAD', '/api/files/stream')).toBe(false) + expect(isPhoneForbidden('GET', '/api/files/stream/x')).toBe(true) + expect(isPhoneForbidden('GET', '/api/environment')).toBe(true) + expect(isPhoneForbidden('GET', '/api/sessions')).toBe(false) + expect(isPhoneForbidden('POST', '/api/sessions')).toBe(false) + }) + + it('firewall command: fixed arguments, numeric port only, no shell; exit codes map to ok / denied / failed', async () => { + const add = firewallCommand('add', 47321, 'C:\\Windows') + + expect(add.file).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') + expect(add.args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) + expect(add.args[5]).toContain("Start-Process -FilePath 'C:\\Windows\\System32\\netsh.exe' -Verb RunAs") + expect(add.args[5]).toContain( + `-ArgumentList 'advfirewall firewall add rule name="AITURK IDE Telefon Baglantisi" dir=in action=allow protocol=TCP localport=47321 profile=private,public'` + ) + expect(firewallCommand('remove', 47321).args[5]).toContain(`delete rule name="AITURK IDE Telefon Baglantisi"'`) + expect(firewallCommand('add', 47321, "C:\\x'; calc; '").file.startsWith('C:\\Windows\\')).toBe(true) + + for (const bad of [0, 80, 70000, 1.5, Number.NaN, '47321; calc' as any]) { + expect(() => firewallCommand('add', bad)).toThrow('aiturk-mobile:invalid_port') + } + + for (const [exit, expected] of [[0, { ok: true }], [77, { ok: false, reason: 'denied' }], [1, { ok: false, reason: 'failed' }]] as const) { + const spawned: any[] = [] + + const firewall = createWindowsFirewall({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + spawn: (file, args, options) => { + spawned.push({ file, args, options }) + + return { + on: (event: string, listener: (...values: any[]) => void) => { + if (event === 'exit') { + setTimeout(() => listener(exit), 0) + } + } + } + } + }) + + expect(await firewall.addRule(47321)).toEqual(expected) + expect(spawned[0].options).toMatchObject({ shell: false, windowsHide: true }) + } + + const never = createWindowsFirewall({ + platform: 'linux', + env: {}, + spawn: () => { + throw new Error('must not spawn') + } + }) + + expect(await never.addRule(47321)).toEqual({ ok: true }) + }) + + it('stored state is validated on load (bad rows dropped, no token field survives)', () => { + const hash = 'a'.repeat(64) + const id = '0f8fad5b-d9cb-469f-a165-70867728950e' + + expect( + parseMobileLinkStored({ + enabled: 'yes', + port: 99999, + devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null] + }) + ).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }] }) + expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [] }) + }) +}) diff --git a/apps/desktop/electron/mobile-lan-proxy.ts b/apps/desktop/electron/mobile-lan-proxy.ts new file mode 100644 index 0000000..000d4cd --- /dev/null +++ b/apps/desktop/electron/mobile-lan-proxy.ts @@ -0,0 +1,1603 @@ +/** + * mobile-lan-proxy.ts + * + * "Telefon bağlantısı": lets the AITURK Mobil Android app reach this IDE's + * local backend from the same Wi-Fi / LAN. The backend itself keeps listening + * on 127.0.0.1 only; this module adds a small TLS reverse proxy in the Electron + * main process plus the pairing session that hands a phone its own token. + * + * Contract: workspace/aiturk-mobil-eslestirme-spec.md (v1). The Android app and + * the gateway rendezvous endpoints are written against the same document, so + * paths, field names and error codes here must not drift. + * + * Security contract (do not relax): + * - `/` and everything outside `/api/` is answered here with 404 and never + * forwarded: the backend's root page embeds its session token. + * - A phone only ever knows its own device token (`amd_…`). The backend's + * real session token is added here and never sent to the phone. Only the + * SHA-256 of a device token is kept (memory and disk). + * - Authentication happens BEFORE the backend is resolved, so an + * unauthenticated LAN request can never start a backend. + * - Tokens, pairing codes and bodies are never logged or put into error + * messages; the log line is method + path (no query) + status. + * - The rendezvous record holds no secret: the raw code never leaves this + * machine, only hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)). + * + * All I/O that matters for tests is injected (backend resolver, clock, + * randomness, store, vault, firewall, fetch, address discovery); node's + * https/net modules are used for real so the tests exercise real TLS sockets. + */ + +import crypto from 'node:crypto' +import dgram from 'node:dgram' +import http from 'node:http' +import https from 'node:https' +import net from 'node:net' +import type { Duplex } from 'node:stream' + +import { loadOrCreateLanIdentity, type MobileLanIdentity, type MobileLanVault } from './mobile-lan-cert' + +export const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +export const PAIR_CODE_LENGTH = 8 +export const PAIR_CODE_TTL_MS = 5 * 60_000 +export const PAIR_MAX_WRONG_ATTEMPTS = 5 +export const PAIR_CLAIM_MAX_BODY_BYTES = 4096 +export const MOBILE_LAN_DEFAULT_PORT = 47321 +export const MOBILE_LAN_LAST_PORT = 47340 +export const MOBILE_LAN_MAX_URLS = 4 +export const MOBILE_PAIR_ORIGIN = 'https://turkservis.online' +export const MOBILE_PAIR_FETCH_TIMEOUT_MS = 20_000 +export const MOBILE_FIREWALL_RULE_NAME = 'AITURK IDE Telefon Baglantisi' +export const MOBILE_LAN_WS_PATHS: readonly string[] = ['/api/ws', '/api/plugins/kanban/events'] + +const DEVICE_TOKEN_RE = /^amd_[A-Za-z0-9_-]{43}$/ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const CANCEL_TOKEN_RE = /^tsmp_[A-Za-z0-9_-]{43}$/ +const HASH_RE = /^[0-9a-f]{64}$/ +const LAST_SEEN_PERSIST_MS = 60_000 + +const sha256Hex = (value: string) => crypto.createHash('sha256').update(value).digest('hex') + +// ── Pairing code ──────────────────────────────────────────────────────────── + +/** Upper-case and drop spaces / dashes, as the contract's comparison rule says. */ +export function normalizePairCode(value: unknown): string { + return typeof value === 'string' ? value.toUpperCase().replace(/[\s-]/g, '') : '' +} + +export function generatePairCode(randomBytes: (size: number) => Buffer = crypto.randomBytes): string { + // 256 % 32 === 0, so masking a byte to 5 bits is uniform over the alphabet. + const bytes = randomBytes(PAIR_CODE_LENGTH) + let code = '' + + for (let i = 0; i < PAIR_CODE_LENGTH; i++) { + code += PAIR_CODE_ALPHABET[bytes[i] & 31] + } + + return code +} + +/** Display form only: ABCD-EFGH. */ +export function formatPairCode(code: string): string { + return `${code.slice(0, 4)}-${code.slice(4)}` +} + +/** hex(SHA-256("aiturk-mobile-pair-v1:" + CODE)) — the only thing the gateway sees. */ +export function pairLookup(code: string): string { + return sha256Hex(`aiturk-mobile-pair-v1:${normalizePairCode(code)}`) +} + +// ── Local address candidates ──────────────────────────────────────────────── + +export function isPrivateIpv4(address: string): boolean { + const parts = String(address).split('.') + + if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part) || Number(part) > 255)) { + return false + } + + const [a, b] = parts.map(Number) + + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) +} + +const VIRTUAL_ADAPTER_RE = + /vethernet|virtualbox|vmware|wsl|hyper-v|loopback|docker|tailscale|zerotier|wireguard|bluetooth|vpn|\b(?:tap|tun)\d*\b/i + +export interface LanInterfaceInfo { + address: string + family: string | number + internal: boolean +} + +/** + * Pick up to `max` private IPv4 addresses a phone on the same LAN could reach. + * The default-route interface goes first; virtual adapters are dropped (unless + * nothing else is left and the default route itself is the only candidate). + */ +export function selectLanAddresses( + interfaces: Record, + defaultRoute: null | string, + max = MOBILE_LAN_MAX_URLS +): string[] { + const rows: { address: string; primary: boolean; virtual: boolean }[] = [] + + for (const [name, list] of Object.entries(interfaces ?? {})) { + for (const item of list ?? []) { + const ipv4 = item.family === 'IPv4' || item.family === 4 + + if (ipv4 && !item.internal && isPrivateIpv4(item.address) && !rows.some(row => row.address === item.address)) { + rows.push({ address: item.address, primary: item.address === defaultRoute, virtual: VIRTUAL_ADAPTER_RE.test(name) }) + } + } + } + + const real = rows.filter(row => !row.virtual).sort((a, b) => Number(b.primary) - Number(a.primary)) + const chosen = real.length ? real : rows.filter(row => row.primary) + + return chosen.slice(0, max).map(row => row.address) +} + +/** + * Local address of the default route. Connecting a UDP socket only selects a + * route; no packet is sent. + */ +export function defaultRouteAddress(): Promise { + return new Promise(resolve => { + let socket: dgram.Socket + + try { + socket = dgram.createSocket('udp4') + } catch { + resolve(null) + + return + } + + const finish = (value: null | string) => { + try { + socket.close() + } catch { + // already closed + } + + resolve(value) + } + + socket.once('error', () => finish(null)) + + try { + socket.connect(53, '1.1.1.1', () => { + let address: null | string = null + + try { + address = socket.address().address + } catch { + address = null + } + + finish(address) + }) + } catch { + finish(null) + } + }) +} + +// ── QR payload ────────────────────────────────────────────────────────────── + +/** `aiturk://pair?v=1&u=[&u=…]&fp=<43>&c=` (contract §2). */ +export function buildPairQrPayload(input: { addresses: string[]; port: number; fingerprint: string; code: string }): string { + const hosts = input.addresses.filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) + + if (!hosts.length || !Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { + throw new MobileLinkError('no_lan_address') + } + + // ip:port, the fingerprint (base64url) and the code need no escaping in a query. + return `aiturk://pair?v=1&${hosts.map(host => `u=${host}:${input.port}`).join('&')}&fp=${input.fingerprint}&c=${input.code}` +} + +// ── Gateway rendezvous client (contract §3; server side lives in the gateway) ─ + +export interface PairFetchResponse { + status: number + json: () => Promise +} + +export type PairFetch = (url: string, init: Record) => Promise + +export interface PairOffer { + offerId: string + cancelToken: string +} + +function pairFetchInit(body: unknown, headers: Record = {}) { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers }, + body: JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(MOBILE_PAIR_FETCH_TIMEOUT_MS) + } +} + +/** + * Publish the rendezvous record. Returns null on any failure — the QR path + * keeps working and the UI shows "kodla bağlanma kullanılamıyor". + */ +export async function publishPairOffer( + fetcher: PairFetch, + input: { code: string; addresses: string[]; port: number; fingerprint: string } +): Promise { + const urls = input.addresses + .filter(isPrivateIpv4) + .slice(0, MOBILE_LAN_MAX_URLS) + .map(host => `https://${host}:${input.port}`) + + if (!urls.length) { + return null + } + + try { + const response = await fetcher( + `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, + pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint }) + ) + + if (response.status !== 201) { + return null + } + + const data = (await response.json()) as null | Record + + if (!data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId)) { + return null + } + + if (typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken)) { + return null + } + + return { offerId: data.offerId, cancelToken: data.cancelToken } + } catch { + return null + } +} + +/** Best effort; the record expires on its own after five minutes anyway. */ +export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise { + try { + await fetcher( + `${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`, + pairFetchInit({}, { Authorization: `Bearer ${offer.cancelToken}` }) + ) + } catch { + // nothing to do + } +} + +// ── Windows Firewall ──────────────────────────────────────────────────────── + +export interface FirewallResult { + ok: boolean + /** Set when `ok` is false. `denied` = the user refused the UAC prompt. */ + reason?: 'denied' | 'failed' +} + +export interface MobileFirewall { + addRule: (port: number) => Promise + removeRule: () => Promise +} + +const UAC_DENIED_EXIT = 77 + +function assertPort(port: number): number { + if (!Number.isInteger(port) || port < 1024 || port > 65535) { + throw new MobileLinkError('invalid_port') + } + + return port +} + +/** + * Fixed command lines for the inbound rule. One elevated `netsh` call through + * `Start-Process -Verb RunAs` (a single UAC prompt). The only variable part is + * the numeric port, validated above; nothing here comes from user input and no + * shell is involved (the caller spawns PowerShell with an argument array). + */ +export function firewallCommand(action: 'add' | 'remove', port: number, systemRoot?: string) { + const root = systemRoot && /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]{1,100}$/.test(systemRoot) ? systemRoot.replace(/\\+$/, '') : 'C:\\Windows' + + const netshArgs = + action === 'add' + ? `advfirewall firewall add rule name="${MOBILE_FIREWALL_RULE_NAME}" dir=in action=allow protocol=TCP localport=${assertPort(port)} profile=private,public` + : `advfirewall firewall delete rule name="${MOBILE_FIREWALL_RULE_NAME}"` + + const script = + `try { $p = Start-Process -FilePath '${root}\\System32\\netsh.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru ` + + `-ArgumentList '${netshArgs}'; exit $p.ExitCode } catch { exit ${UAC_DENIED_EXIT} }` + + return { + file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script] + } +} + +export interface FirewallSpawn { + (file: string, args: string[], options: Record): { + on: (event: string, listener: (...args: any[]) => void) => unknown + } +} + +/** Real implementation; tests inject a fake `MobileFirewall` instead. */ +export function createWindowsFirewall(deps: { + spawn: FirewallSpawn + platform: string + env: Record +}): MobileFirewall { + const run = (action: 'add' | 'remove', port: number) => + new Promise(resolve => { + if (deps.platform !== 'win32') { + resolve({ ok: true }) + + return + } + + let settled = false + + const finish = (result: FirewallResult) => { + if (!settled) { + settled = true + resolve(result) + } + } + + try { + const command = firewallCommand(action, port, deps.env.SystemRoot) + const child = deps.spawn(command.file, command.args, { shell: false, windowsHide: true, stdio: 'ignore' }) + + child.on('error', () => finish({ ok: false, reason: 'failed' })) + child.on('exit', (code: null | number) => + finish(code === 0 ? { ok: true } : { ok: false, reason: code === UAC_DENIED_EXIT ? 'denied' : 'failed' }) + ) + } catch { + finish({ ok: false, reason: 'failed' }) + } + }) + + return { addRule: port => run('add', port), removeRule: () => run('remove', MOBILE_LAN_DEFAULT_PORT) } +} + +// ── What a phone may not reach ────────────────────────────────────────────── + +type DenyRule = [prefix: RegExp, scope: 'all' | 'write'] + +/** + * Contract §1 "Telefondan yasak". Matched case-insensitively against the + * percent-DECODED path (the backend decodes before routing, so matching the + * raw path would let `/api/%65nv` through). + */ +const DENY_RULES: readonly DenyRule[] = [ + [/^\/api\/env/i, 'all'], + [/^\/api\/config\/raw/i, 'all'], + // Reading settings is fine; changing them from a phone is not. + [/^\/api\/config(?:\/|$)/i, 'write'], + [/^\/api\/ops(?:\/|$)/i, 'all'], + [/^\/api\/hermes\/update/i, 'all'], + [/^\/api\/credentials/i, 'all'], + [/^\/api\/providers/i, 'write'], + [/^\/api\/mcp(?:\/|$)/i, 'write'], + [/^\/api\/webhooks/i, 'write'], + // Plugin install / enable / disable / update / remove. + [/^\/api\/dashboard\/agent-plugins/i, 'all'], + [/^\/api\/files(?:\/|$)/i, 'all'] +] + +const FILES_READ_ALLOWED = new Set(['/api/files/download', '/api/files/stream']) + +export function isPhoneForbidden(method: string, decodedPath: string): boolean { + const read = method === 'GET' || method === 'HEAD' + + if (read && FILES_READ_ALLOWED.has(decodedPath)) { + return false + } + + return DENY_RULES.some(([prefix, scope]) => prefix.test(decodedPath) && (scope === 'all' || !read)) +} + +/** Decoded path, or null when the request target is something we refuse to interpret. */ +function decodeRequestPath(pathname: string): null | string { + let decoded: string + + try { + decoded = decodeURIComponent(pathname) + } catch { + return null + } + + // Control characters, backslashes, a second layer of encoding, empty and dot segments. + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f\\%]/.test(decoded) || decoded.includes('//') || /(?:^|\/)\.\.?(?:\/|$)/.test(decoded)) { + return null + } + + return decoded +} + +// ── The proxy ─────────────────────────────────────────────────────────────── + +export interface MobileBackend { + baseUrl: string + token: string +} + +export interface MobileDeviceRecord { + id: string + name: string + /** hex SHA-256 of the device token; the token itself is never stored. */ + tokenHash: string + createdAt: number + lastSeenAt: number +} + +export interface MobileDeviceView { + id: string + name: string + createdAt: number + lastSeenAt: number + /** Open connections right now (WebSocket or in-flight request). */ + live: number +} + +export type PairingEndReason = 'claimed' | 'locked' | 'expired' | 'cancelled' + +export interface LanProxyOptions { + tls: { key: string; cert: string } + /** Must resolve the LOCAL backend or throw (remote mode, backend down). */ + resolveBackend: () => Promise + version: string + pcName: string + devices?: MobileDeviceRecord[] + now?: () => number + randomBytes?: (size: number) => Buffer + randomUUID?: () => string + /** method + path + status only. */ + log?: (line: string) => void + /** Persist; awaited before a claim answers. Throwing fails the claim. */ + onDevicesChanged?: (devices: MobileDeviceRecord[]) => Promise | void + onPairingEnded?: (reason: PairingEndReason, device?: MobileDeviceRecord) => void + codeTtlMs?: number + maxWrongAttempts?: number +} + +interface LiveDevice extends MobileDeviceRecord { + sockets: Set<{ destroy: () => unknown }> + persistedSeenAt: number +} + +const DROPPED_REQUEST_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', + 'authorization', + 'x-hermes-session-token', + 'cookie', + 'origin', + 'referer', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-forwarded-prefix', + 'x-real-ip', + 'forwarded' +]) + +const DROPPED_RESPONSE_HEADERS = ['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'proxy-authenticate'] +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']) +const STATUS_TEXT: Record = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 503: 'Service Unavailable' } + +export function createLanProxy(options: LanProxyOptions) { + const now = options.now ?? Date.now + const randomBytes = options.randomBytes ?? crypto.randomBytes + const randomUUID = options.randomUUID ?? crypto.randomUUID + const codeTtlMs = options.codeTtlMs ?? PAIR_CODE_TTL_MS + const maxWrongAttempts = options.maxWrongAttempts ?? PAIR_MAX_WRONG_ATTEMPTS + const devices = new Map() + const sockets = new Set() + let pairing: null | { codeHash: Buffer; expiresAt: number; attempts: number } = null + let listeningPort: null | number = null + + for (const row of options.devices ?? []) { + devices.set(row.tokenHash, { ...row, sockets: new Set(), persistedSeenAt: row.lastSeenAt }) + } + + const records = (): MobileDeviceRecord[] => + [...devices.values()].map(({ id, name, tokenHash, createdAt, lastSeenAt }) => ({ id, name, tokenHash, createdAt, lastSeenAt })) + + const log = (method: string | undefined, path: string, status: number) => { + if (options.log) { + // Path only (no query), bounded; a token-shaped segment is masked defensively. + const safe = path.slice(0, 160).replace(/amd_[A-Za-z0-9_-]*/g, '[redacted]') + + options.log(`${String(method ?? '').slice(0, 10)} ${safe} ${status}`) + } + } + + const reply = (req: http.IncomingMessage, res: http.ServerResponse, path: string, status: number, body: unknown) => { + log(req.method, path, status) + + if (res.headersSent) { + res.destroy() + + return + } + + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }) + res.end(JSON.stringify(body)) + } + + function credential(req: http.IncomingMessage, url: URL): string { + const auth = req.headers.authorization + + if (typeof auth === 'string' && /^bearer /i.test(auth)) { + return auth.slice(7).trim() + } + + const header = req.headers['x-hermes-session-token'] + + if (typeof header === 'string' && header) { + return header + } + + return url.searchParams.get('token') ?? '' + } + + function authenticate(req: http.IncomingMessage, url: URL): LiveDevice | undefined { + const token = credential(req, url) + + // Shape check first: the backend's own session token is not `amd_…`. + if (!DEVICE_TOKEN_RE.test(token)) { + return undefined + } + + const device = devices.get(sha256Hex(token)) + + if (device) { + device.lastSeenAt = now() + + if (device.lastSeenAt - device.persistedSeenAt >= LAST_SEEN_PERSIST_MS) { + device.persistedSeenAt = device.lastSeenAt + void Promise.resolve(options.onDevicesChanged?.(records())).catch(() => undefined) + } + } + + return device + } + + async function localBackend(): Promise { + try { + const backend = await options.resolveBackend() + const target = new URL(backend.baseUrl) + + // Never forward a phone to anything but the loopback backend. + if (target.protocol !== 'http:' || !LOOPBACK_HOSTS.has(target.hostname) || !backend.token) { + return null + } + + return { target, token: backend.token } + } catch { + return null + } + } + + function endPairing(reason: PairingEndReason, device?: MobileDeviceRecord) { + pairing = null + options.onPairingEnded?.(reason, device) + } + + function pairingOpen(): boolean { + if (pairing && pairing.expiresAt <= now()) { + endPairing('expired') + } + + return pairing !== null + } + + /** Body text, or null when it is larger than the limit. */ + async function readClaimBody(req: http.IncomingMessage): Promise { + if (Number(req.headers['content-length'] ?? 0) > PAIR_CLAIM_MAX_BODY_BYTES) { + return null + } + + const chunks: Buffer[] = [] + let size = 0 + + for await (const chunk of req) { + size += chunk.length + + if (size > PAIR_CLAIM_MAX_BODY_BYTES) { + return null + } + + chunks.push(chunk) + } + + return Buffer.concat(chunks).toString('utf8') + } + + async function claim(req: http.IncomingMessage, res: http.ServerResponse, path: string) { + const body = await readClaimBody(req) + + if (body === null) { + res.setHeader('connection', 'close') + + return reply(req, res, path, 413, { error: 'too_large' }) + } + + let data: any + + try { + data = JSON.parse(body) + } catch { + return reply(req, res, path, 400, { error: 'invalid_request' }) + } + + // eslint-disable-next-line no-control-regex + const name = typeof data?.device_name === 'string' ? data.device_name.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() : '' + + if (!data || typeof data !== 'object' || typeof data.code !== 'string' || name.length < 1 || name.length > 60) { + return reply(req, res, path, 400, { error: 'invalid_request' }) + } + + if (!pairingOpen() || !pairing) { + return reply(req, res, path, 410, { error: 'pairing_closed' }) + } + + // Constant time: both sides are fixed-length digests. + const offered = crypto.createHash('sha256').update(normalizePairCode(data.code)).digest() + + if (!crypto.timingSafeEqual(offered, pairing.codeHash)) { + if (++pairing.attempts >= maxWrongAttempts) { + endPairing('locked') + + return reply(req, res, path, 410, { error: 'pairing_locked' }) + } + + return reply(req, res, path, 401, { error: 'wrong_code' }) + } + + // Single use: consume first, then mint the token. + pairing = null + + const token = `amd_${randomBytes(32).toString('base64url')}` + const stamp = now() + + const device: LiveDevice = { + id: randomUUID(), + name, + tokenHash: sha256Hex(token), + createdAt: stamp, + lastSeenAt: stamp, + sockets: new Set(), + persistedSeenAt: stamp + } + + devices.set(device.tokenHash, device) + + try { + await options.onDevicesChanged?.(records()) + } catch { + devices.delete(device.tokenHash) + options.onPairingEnded?.('cancelled') + + return reply(req, res, path, 500, { error: 'internal' }) + } + + options.onPairingEnded?.('claimed', records().find(row => row.id === device.id)) + + return reply(req, res, path, 200, { token, device_id: device.id, pc_name: options.pcName, version: options.version }) + } + + function forward(req: http.IncomingMessage, res: http.ServerResponse, url: URL, path: string, device: LiveDevice, backend: { target: URL; token: string }) { + const headers: Record = {} + + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined && !DROPPED_REQUEST_HEADERS.has(key)) { + headers[key] = value + } + } + + headers.host = backend.target.host + headers['x-hermes-session-token'] = backend.token + + if (url.searchParams.has('token')) { + url.searchParams.set('token', backend.token) + } + + const clientSocket = req.socket + + device.sockets.add(clientSocket) + + const upstream = http.request( + { + host: '127.0.0.1', + port: Number(backend.target.port) || 80, + method: req.method, + path: url.pathname + url.search, + headers + }, + answer => { + const answerHeaders = { ...answer.headers } + + for (const key of DROPPED_RESPONSE_HEADERS) { + delete answerHeaders[key] + } + + log(req.method, path, answer.statusCode ?? 502) + res.writeHead(answer.statusCode ?? 502, answerHeaders) + answer.pipe(res) + } + ) + + const release = () => { + // Keep WebSocket/other requests on the same device tracked independently. + device.sockets.delete(clientSocket) + } + + upstream.on('error', () => { + release() + + if (res.headersSent) { + res.destroy() + } else { + reply(req, res, path, 502, { error: 'backend_unreachable' }) + } + }) + res.on('close', () => { + release() + upstream.destroy() + }) + req.pipe(upstream) + } + + async function onRequest(req: http.IncomingMessage, res: http.ServerResponse) { + const raw = req.url ?? '' + + if (!raw.startsWith('/') || raw.startsWith('//')) { + return reply(req, res, '(invalid)', 400, { error: 'invalid_request' }) + } + + const url = new URL(raw, 'http://proxy.invalid') + const path = decodeRequestPath(url.pathname) + + if (path === null) { + return reply(req, res, '(invalid)', 400, { error: 'invalid_request' }) + } + + if (path === '/aiturk/pair/hello' && req.method === 'GET') { + return reply(req, res, path, 200, { app: 'aiturk-ide', version: options.version, pairing: pairingOpen() }) + } + + if (path === '/aiturk/pair/claim' && req.method === 'POST') { + return claim(req, res, path) + } + + // The backend's `/` embeds its session token: nothing outside /api/ is ever forwarded. + if (!path.startsWith('/api/')) { + return reply(req, res, path, 404, { error: 'not_found' }) + } + + const device = authenticate(req, url) + + if (!device) { + return reply(req, res, path, 401, { error: 'unauthorized' }) + } + + if (isPhoneForbidden(req.method ?? '', path)) { + return reply(req, res, path, 403, { error: 'forbidden' }) + } + + const backend = await localBackend() + + if (!backend) { + return reply(req, res, path, 503, { error: 'backend_unavailable' }) + } + + // Revoked while the backend was being resolved. + if (!devices.has(device.tokenHash)) { + return reply(req, res, path, 401, { error: 'unauthorized' }) + } + + forward(req, res, url, path, device, backend) + } + + async function onUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer) { + const refuse = (path: string, status: number, error: string) => { + const body = JSON.stringify({ error }) + + log(req.method, path, status) + socket.end( + `HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Error'}\r\nConnection: close\r\nContent-Type: application/json\r\n` + + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` + ) + } + + socket.on('error', () => socket.destroy()) + + const raw = req.url ?? '' + const url = raw.startsWith('/') && !raw.startsWith('//') ? new URL(raw, 'http://proxy.invalid') : null + const path = url ? decodeRequestPath(url.pathname) : null + + if (!url || path === null) { + return refuse('(invalid)', 400, 'invalid_request') + } + + if (!path.startsWith('/api/')) { + return refuse(path, 404, 'not_found') + } + + const device = authenticate(req, url) + + if (!device) { + return refuse(path, 401, 'unauthorized') + } + + if (!MOBILE_LAN_WS_PATHS.includes(path)) { + return refuse(path, 403, 'forbidden') + } + + const backend = await localBackend() + + if (!backend) { + return refuse(path, 503, 'backend_unavailable') + } + + if (!devices.has(device.tokenHash) || socket.destroyed) { + return refuse(path, 401, 'unauthorized') + } + + url.searchParams.set('token', backend.token) + + const upstream = net.connect(Number(backend.target.port) || 80, '127.0.0.1') + let closed = false + + const close = () => { + if (!closed) { + closed = true + device.sockets.delete(socket) + sockets.delete(upstream) + upstream.destroy() + socket.destroy() + } + } + + device.sockets.add(socket) + sockets.add(upstream) + upstream.on('error', close) + upstream.on('close', close) + socket.on('close', close) + upstream.once('connect', () => { + // No Origin / Cookie / Authorization: only what the WebSocket handshake needs. + const lines = [`GET ${path}${url.search} HTTP/1.1`, `Host: ${backend.target.host}`, 'Connection: Upgrade', 'Upgrade: websocket'] + + for (const key of ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol']) { + const value = req.headers[key] + + if (typeof value === 'string' && value) { + lines.push(`${key}: ${value}`) + } + } + + log(req.method, path, 101) + upstream.write(`${lines.join('\r\n')}\r\n\r\n`) + + if (head?.length) { + upstream.write(head) + } + + upstream.pipe(socket) + socket.pipe(upstream) + }) + } + + const server = https.createServer({ key: options.tls.key, cert: options.tls.cert, minVersion: 'TLSv1.2' }, (req, res) => { + onRequest(req, res).catch(() => reply(req, res, '(error)', 500, { error: 'internal' })) + }) + + server.on('upgrade', (req, socket, head) => { + onUpgrade(req, socket, head).catch(() => socket.destroy()) + }) + server.on('connection', socket => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + server.on('clientError', (_error, socket) => socket.destroy()) + server.on('tlsClientError', () => undefined) + + return { + get port() { + return listeningPort + }, + get listening() { + return listeningPort !== null + }, + listen(port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error) + + server.once('error', onError) + server.listen(port, host, () => { + server.removeListener('error', onError) + server.on('error', () => undefined) + listeningPort = (server.address() as net.AddressInfo).port + resolve(listeningPort) + }) + }) + }, + stop(): Promise { + return new Promise(resolve => { + if (listeningPort === null) { + resolve() + + return + } + + listeningPort = null + + for (const socket of sockets) { + socket.destroy() + } + + server.close(() => resolve()) + }) + }, + /** Starts a pairing session; any previous one is dropped. The code is returned once. */ + beginPairing(): { code: string; expiresAt: number } { + const code = generatePairCode(randomBytes) + + pairing = { codeHash: crypto.createHash('sha256').update(code).digest(), expiresAt: now() + codeTtlMs, attempts: 0 } + + return { code, expiresAt: pairing.expiresAt } + }, + cancelPairing(): boolean { + if (!pairing) { + return false + } + + endPairing('cancelled') + + return true + }, + pairingOpen, + records, + devices: (): MobileDeviceView[] => + [...devices.values()].map(({ id, name, createdAt, lastSeenAt, sockets: live }) => ({ id, name, createdAt, lastSeenAt, live: live.size })), + /** Removes the device: its token turns 401 and its open connections are closed. */ + async revoke(id: string): Promise { + for (const [hash, device] of devices) { + if (device.id === id) { + devices.delete(hash) + + for (const socket of device.sockets) { + socket.destroy() + } + + device.sockets.clear() + await options.onDevicesChanged?.(records()) + + return true + } + } + + return false + } + } +} + +export type LanProxy = ReturnType + +// ── Persistent state (no secrets: token digests only) ─────────────────────── + +export interface MobileLinkStored { + enabled: boolean + port: null | number + /** Port the Windows Firewall rule was last added for. */ + firewallPort: null | number + /** Fingerprint the devices were paired against. */ + fingerprint: null | string + devices: MobileDeviceRecord[] +} + +export interface MobileLinkStore { + load: () => Promise + save: (value: MobileLinkStored) => Promise +} + +export interface MobileLinkStoreFs { + readFile: (file: string, encoding: 'utf8') => Promise + writeFile: (file: string, data: string, options: { mode: number }) => Promise + rename: (from: string, to: string) => Promise + mkdir: (directory: string, options: { recursive: true }) => Promise +} + +export function createMobileLinkFileStore(directory: string, file: string, fs: MobileLinkStoreFs): MobileLinkStore { + return { + async load() { + try { + return JSON.parse(await fs.readFile(file, 'utf8')) + } catch { + return null + } + }, + async save(value) { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(`${file}.tmp`, JSON.stringify({ v: 1, ...value }), { mode: 0o600 }) + await fs.rename(`${file}.tmp`, file) + } + } +} + +const validPort = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0 && (value as number) < 65536 + +export function parseMobileLinkStored(raw: unknown): MobileLinkStored { + const row = (raw && typeof raw === 'object' ? raw : {}) as Record + const devices: MobileDeviceRecord[] = [] + + for (const item of Array.isArray(row.devices) ? row.devices : []) { + const device = item as MobileDeviceRecord + + if ( + device && + typeof device.id === 'string' && + UUID_RE.test(device.id) && + typeof device.name === 'string' && + typeof device.tokenHash === 'string' && + HASH_RE.test(device.tokenHash) && + Number.isFinite(device.createdAt) + ) { + devices.push({ + id: device.id, + name: device.name.slice(0, 60), + tokenHash: device.tokenHash, + createdAt: device.createdAt, + lastSeenAt: Number.isFinite(device.lastSeenAt) ? device.lastSeenAt : device.createdAt + }) + } + } + + return { + enabled: row.enabled === true, + port: validPort(row.port) ? row.port : null, + firewallPort: validPort(row.firewallPort) ? row.firewallPort : null, + fingerprint: typeof row.fingerprint === 'string' ? row.fingerprint : null, + devices + } +} + +// ── The controller the window and main.ts talk to ─────────────────────────── + +export type MobileLinkErrorCode = + | 'remote_mode' + | 'vault_unavailable' + | 'not_enabled' + | 'no_lan_address' + | 'port_unavailable' + | 'invalid_port' + | 'invalid_request' + | 'unsupported' + +/** Message is ONLY a fixed code, so nothing secret can ride an error to the UI or the log. */ +export class MobileLinkError extends Error { + code: MobileLinkErrorCode + + constructor(code: MobileLinkErrorCode) { + super(`aiturk-mobile:${code}`) + this.name = 'MobileLinkError' + this.code = code + } +} + +export type MobileFirewallState = 'ok' | 'needed' | 'denied' | 'failed' | 'not-needed' + +export interface MobileLinkState { + enabled: boolean + listening: boolean + port: null | number + /** IDE is in remote-gateway mode: the feature is refused. */ + remote: boolean + firewall: MobileFirewallState + pcName: string + pairing: null | { + /** Raw 8 characters; the window shows it as ABCD-EFGH. */ + code: string + qr: string + expiresAt: number + /** Gateway rendezvous for the "type the code" path. */ + codePath: 'pending' | 'ready' | 'unavailable' + } + devices: MobileDeviceView[] +} + +export type MobileLinkEvent = { type: 'state'; state: MobileLinkState } | { type: 'paired'; name: string; state: MobileLinkState } + +export interface MobileLinkDeps { + vault: MobileLanVault + store: MobileLinkStore + resolveBackend: () => Promise + isRemoteMode: () => boolean + firewall: MobileFirewall + fetch: PairFetch + /** Private IPv4 candidates, default route first. */ + addresses: () => Promise + version: string + pcName: string + platform: string + listenHost?: string + /** Candidate ports, tried in order when none is stored yet (or the stored one is taken). */ + ports?: number[] + now?: () => number + randomBytes?: (size: number) => Buffer + randomUUID?: () => string + log?: (line: string) => void + setTimer?: (fn: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void +} + +function defaultPorts(): number[] { + const ports: number[] = [] + + for (let port = MOBILE_LAN_DEFAULT_PORT; port <= MOBILE_LAN_LAST_PORT; port++) { + ports.push(port) + } + + return ports +} + +/** Find a free port by binding and releasing it; nothing is ever accepted on it. */ +function probeFreePort(port: number, host: string): Promise { + return new Promise(resolve => { + const probe = net.createServer() + + probe.once('error', () => resolve(null)) + probe.listen(port, host, () => { + const actual = (probe.address() as net.AddressInfo).port + + probe.close(() => resolve(actual)) + }) + }) +} + +export function createMobileLink(deps: MobileLinkDeps) { + const now = deps.now ?? Date.now + const listenHost = deps.listenHost ?? '0.0.0.0' + const ports = deps.ports ?? defaultPorts() + const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)) + const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout)) + const listeners = new Set<(event: MobileLinkEvent) => void>() + const pcName = (deps.pcName || 'AITURK IDE').slice(0, 60) + + let stored: MobileLinkStored | null = null + let identity: MobileLanIdentity | null = null + let proxy: LanProxy | null = null + let firewallFailure: null | 'denied' | 'failed' = null + let session: null | { code: string; qr: string; expiresAt: number; codePath: 'pending' | 'ready' | 'unavailable'; offer: null | PairOffer; timer: unknown } = null + let queue: Promise = Promise.resolve() + let stopped = false + + /** Actions run one at a time so two IPC calls cannot interleave listen/stop. */ + function serial(work: () => Promise): Promise { + const next = queue.then(work, work) + + queue = next.catch(() => undefined) + + return next + } + + async function load(): Promise { + if (!stored) { + stored = parseMobileLinkStored(await deps.store.load()) + } + + return stored + } + + const persist = async () => { + if (stored) { + await deps.store.save({ ...stored, devices: proxy ? proxy.records() : stored.devices }) + } + } + + function firewallState(data: MobileLinkStored): MobileFirewallState { + if (deps.platform !== 'win32' || data.port === null) { + return 'not-needed' + } + + if (data.firewallPort === data.port) { + return 'ok' + } + + return firewallFailure ?? 'needed' + } + + function snapshot(data: MobileLinkStored): MobileLinkState { + return { + enabled: data.enabled, + listening: Boolean(proxy?.listening), + port: data.port, + remote: deps.isRemoteMode(), + firewall: firewallState(data), + pcName, + pairing: session ? { code: session.code, qr: session.qr, expiresAt: session.expiresAt, codePath: session.codePath } : null, + devices: proxy + ? proxy.devices() + : data.devices.map(({ id, name, createdAt, lastSeenAt }) => ({ id, name, createdAt, lastSeenAt, live: 0 })) + } + } + + function emit(extra?: { paired: string }) { + if (!stored) { + return + } + + const state = snapshot(stored) + const event: MobileLinkEvent = extra ? { type: 'paired', name: extra.paired, state } : { type: 'state', state } + + for (const listener of listeners) { + try { + listener(event) + } catch { + // a broken listener must not break the proxy + } + } + } + + function dropSession(cancelOffer: boolean) { + const ended = session + + session = null + + if (ended) { + clearTimer(ended.timer) + + if (cancelOffer && ended.offer) { + void cancelPairOffer(deps.fetch, ended.offer) + } + } + } + + async function ensureProxy(data: MobileLinkStored): Promise { + if (proxy && identity) { + return proxy + } + + try { + identity = await loadOrCreateLanIdentity(deps.vault, { now: () => new Date(now()) }) + } catch { + throw new MobileLinkError('vault_unavailable') + } + + // A new certificate means every phone's pin is stale: those tokens can no + // longer be presented over a connection the phone would trust. + if (data.fingerprint !== identity.fingerprint) { + if (data.fingerprint !== null) { + data.devices = [] + } + + data.fingerprint = identity.fingerprint + await deps.store.save(data) + } + + proxy = createLanProxy({ + tls: identity, + resolveBackend: async () => { + if (deps.isRemoteMode()) { + throw new MobileLinkError('remote_mode') + } + + return deps.resolveBackend() + }, + version: deps.version, + pcName, + devices: data.devices, + now, + randomBytes: deps.randomBytes, + randomUUID: deps.randomUUID, + log: deps.log, + onDevicesChanged: async devices => { + data.devices = devices + await deps.store.save(data) + }, + onPairingEnded: (reason, device) => { + // The gateway record is useless once the code is spent, locked or expired. + dropSession(true) + void serial(() => syncListening(data)).then( + () => emit(reason === 'claimed' && device ? { paired: device.name } : undefined), + () => emit() + ) + } + }) + + return proxy + } + + async function ensurePort(data: MobileLinkStored): Promise { + if (data.port !== null) { + return data.port + } + + for (const candidate of ports) { + const free = await probeFreePort(candidate, listenHost) + + if (free !== null) { + data.port = free + + return free + } + } + + throw new MobileLinkError('port_unavailable') + } + + async function startListening(data: MobileLinkStored, instance: LanProxy) { + if (instance.listening) { + return + } + + const candidates = data.port !== null ? [data.port, ...ports.filter(port => port !== data.port)] : ports + + for (const candidate of candidates) { + try { + const actual = await instance.listen(candidate, listenHost) + + if (data.port !== actual) { + // The rule (if any) was for another port; the UI offers to add it again. + data.port = actual + await persist() + } + + return + } catch { + // taken; try the next one + } + } + + throw new MobileLinkError('port_unavailable') + } + + /** Listen only while enabled AND (a phone is paired OR pairing is in progress). */ + async function syncListening(data: MobileLinkStored) { + const wanted = () => !stopped && data.enabled && !deps.isRemoteMode() && (data.devices.length > 0 || session !== null) + + // ensureProxy may drop phones paired against a replaced certificate, so ask again after it. + const instance = wanted() ? await ensureProxy(data) : proxy + + if (!wanted()) { + await instance?.stop() + + return + } + + await startListening(data, instance as LanProxy) + } + + async function addFirewallRule(data: MobileLinkStored) { + if (deps.platform !== 'win32' || data.port === null || data.firewallPort === data.port) { + return + } + + const result = await deps.firewall.addRule(data.port) + + if (result.ok) { + firewallFailure = null + data.firewallPort = data.port + } else { + firewallFailure = result.reason ?? 'failed' + } + + await persist() + } + + const api = { + onEvent(listener: (event: MobileLinkEvent) => void): () => void { + listeners.add(listener) + + return () => listeners.delete(listener) + }, + + state: () => serial(async () => snapshot(await load())), + + /** App start: resume listening when the feature is on and a phone is paired. No UAC, no pairing. */ + start: () => + serial(async () => { + const data = await load() + + stopped = false + + if (data.enabled && data.devices.length > 0 && !deps.isRemoteMode()) { + await syncListening(data) + } + + return snapshot(data) + }), + + /** App quit / feature teardown: close every socket. Stored state is kept. */ + stop: () => + serial(async () => { + stopped = true + proxy?.cancelPairing() + dropSession(true) + await proxy?.stop() + }), + + enable: () => + serial(async () => { + const data = await load() + + if (deps.isRemoteMode()) { + throw new MobileLinkError('remote_mode') + } + + stopped = false + await ensureProxy(data) + await ensurePort(data) + data.enabled = true + await persist() + // First time on this port: one UAC prompt. A refusal does not undo "enabled"; + // the window shows a warning and "Yeniden dene". + await addFirewallRule(data) + await syncListening(data) + + return snapshot(data) + }), + + /** Stops listening. The firewall rule and the paired phones are kept. */ + disable: () => + serial(async () => { + const data = await load() + + proxy?.cancelPairing() + dropSession(true) + data.enabled = false + await persist() + await syncListening(data) + + return snapshot(data) + }), + + pairBegin: () => + serial(async () => { + const data = await load() + + if (deps.isRemoteMode()) { + throw new MobileLinkError('remote_mode') + } + + if (!data.enabled) { + throw new MobileLinkError('not_enabled') + } + + const instance = await ensureProxy(data) + const addresses = (await deps.addresses()).filter(isPrivateIpv4).slice(0, MOBILE_LAN_MAX_URLS) + + if (!addresses.length) { + throw new MobileLinkError('no_lan_address') + } + + dropSession(true) + await startListening(data, instance) + + const port = data.port as number + const fingerprint = (identity as MobileLanIdentity).fingerprint + const { code, expiresAt } = instance.beginPairing() + + const current = { + code, + qr: buildPairQrPayload({ addresses, port, fingerprint, code }), + expiresAt, + codePath: 'pending' as 'pending' | 'ready' | 'unavailable', + offer: null as null | PairOffer, + // The proxy notices expiry lazily (on a request); this closes the socket on time. + timer: setTimer(() => { + instance.pairingOpen() + }, Math.max(0, expiresAt - now()) + 50) + } + + session = current + + // Not awaited: the QR must not wait for the gateway. + void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint }).then(offer => { + if (session !== current) { + // Pairing ended meanwhile; do not leave the record behind. + if (offer) { + void cancelPairOffer(deps.fetch, offer) + } + + return + } + + current.offer = offer + current.codePath = offer ? 'ready' : 'unavailable' + emit() + }) + + return snapshot(data) + }), + + pairCancel: () => + serial(async () => { + const data = await load() + + proxy?.cancelPairing() + dropSession(true) + await syncListening(data) + + return snapshot(data) + }), + + devices: () => serial(async () => snapshot(await load()).devices), + + deviceRevoke: (id: unknown) => + serial(async () => { + const data = await load() + + if (typeof id !== 'string' || !UUID_RE.test(id)) { + throw new MobileLinkError('invalid_request') + } + + if (proxy) { + await proxy.revoke(id) + } else { + data.devices = data.devices.filter(device => device.id !== id) + await persist() + } + + await syncListening(data) + + return snapshot(data) + }), + + firewallRetry: () => + serial(async () => { + const data = await load() + + if (!data.enabled) { + throw new MobileLinkError('not_enabled') + } + + await addFirewallRule(data) + + return snapshot(data) + }), + + firewallRemove: () => + serial(async () => { + const data = await load() + + if (deps.platform === 'win32') { + const result = await deps.firewall.removeRule() + + if (result.ok) { + data.firewallPort = null + firewallFailure = null + await persist() + } else { + firewallFailure = result.reason ?? 'failed' + } + } + + return snapshot(data) + }) + } + + return api +} + +export type MobileLink = ReturnType diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index fef38d1..a01fec0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -286,6 +286,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { cancel: () => ipcRenderer.invoke('aiturk:login:cancel'), overwriteCli: () => ipcRenderer.invoke('aiturk:login:overwrite-cli') }, + aiturkMobile: { + // Opens the "Telefon bağlantısı" window (electron/mobile-host.mjs). + open: () => ipcRenderer.invoke('aiturk:mobile:open') + }, mcpOauth: { // One-shot loopback listener for MCP OAuth against remote backends: bind // on this machine, hand redirectUri to mcp.servers.oauth.start, then wait diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 754a52a..309d158 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.14", + "version": "1.0.0-beta.15", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs index 0cce6d0..e841e20 100644 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -64,11 +64,13 @@ await build({ logLevel: 'info', }) console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`) -for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cjs']) { +for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-host.css', 'mobile-preload.cjs']) { cpSync(resolve(root, 'electron', name), resolve(distDir, name)) } mkdirSync(resolve(distDir, 'mobile'), { recursive: true }) -for (const name of ['transport.mjs', 'peer-identity.mjs', 'style.css', 'qr.mjs']) { +// The phone window only needs the shared stylesheet and the QR drawer (which +// imports ./vendor/qrcode.mjs, staged below). +for (const name of ['style.css', 'qr.mjs']) { cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name)) } // A clean `npm ci` hoists the mobile vendor packages to the workspace root, so @@ -84,5 +86,3 @@ function mobilePackageDir(name) { } mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true }) cpSync(resolve(mobilePackageDir('qrcode-generator'), 'dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs')) -cpSync(resolve(mobilePackageDir('jsqr'), 'dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js')) -cpSync(resolve(mobilePackageDir('jsqr'), 'LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt')) diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 38a6bf0..ea36d7f 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -1,4 +1,5 @@ import { useStore } from '@nanostores/react' +import { IconDeviceMobile } from '@tabler/icons-react' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useLocation, useNavigate } from 'react-router' @@ -7,6 +8,7 @@ import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' +import { useAiturkCopy } from '@/i18n/aiturk' import { triggerHaptic } from '@/lib/haptics' import { Archive, @@ -73,6 +75,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const { t } = useI18n() + const aiturkCopy = useAiturkCopy() const navigate = useNavigate() const { hash, pathname, search } = useLocation() @@ -249,6 +252,21 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.gateway, onSelect: () => setActiveView('gateway') }, + // "Telefon bağlantısı" lives in its own small window (QR + code); this + // entry only opens it. Present only where the desktop bridge offers it. + ...(window.hermesDesktop?.aiturkMobile + ? [ + { + active: false, + icon: IconDeviceMobile, + id: 'aiturk-phone', + label: aiturkCopy.mobile.nav, + onSelect: () => { + void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed)) + } + } + ] + : []), { active: activeView === 'keybinds', icon: Keyboard, @@ -302,7 +320,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set onSelect: () => setActiveView('about') } ], - [activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView] + [activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView] ) // Type-to-search: printable keystrokes on the Settings surface (outside any diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 1bd0c15..db4ac71 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -331,6 +331,10 @@ declare global { cancel: () => Promise<{ ok: true }> overwriteCli: () => Promise<{ cli: AiturkCliStatus }> } + /** "Telefon bağlantısı": opens the pairing window (electron/mobile-host.mjs). Windows only. */ + aiturkMobile?: { + open: () => Promise<{ ok: true }> + } /** One-shot loopback callback listener for MCP OAuth against remote * backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine, * pass redirectUri as client_redirect_uri to mcp.servers.oauth.start, diff --git a/apps/desktop/src/i18n/aiturk.ts b/apps/desktop/src/i18n/aiturk.ts index 80fb86c..40d7183 100644 --- a/apps/desktop/src/i18n/aiturk.ts +++ b/apps/desktop/src/i18n/aiturk.ts @@ -86,6 +86,10 @@ const en = { found: (n: number) => `Endpoint is reachable. Found ${n} models.`, confirmDelete: (name: string) => `Delete ${name}?` }, + mobile: { + nav: 'Phone connection', + openFailed: 'Could not open the phone connection window.' + }, about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.', downloads: 'Official downloads and updates', source: 'Source code and license', @@ -157,6 +161,10 @@ const tr: typeof en = { found: n => `API adresine erişiliyor. ${n} model bulundu.`, confirmDelete: name => `${name} silinsin mi?` }, + mobile: { + nav: 'Telefon bağlantısı', + openFailed: 'Telefon bağlantısı penceresi açılamadı.' + }, about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.', downloads: 'Resmî indirmeler ve güncellemeler', source: 'Kaynak kodu ve lisans', diff --git a/package-lock.json b/package-lock.json index 712ceb7..9a6cd02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.14", + "version": "1.0.0-beta.15", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",