From 2ed9a6b2de9ae3aa0b9a46674bf1740445baa3c2 Mon Sep 17 00:00:00 2001 From: yilsem Date: Mon, 5 Oct 2026 04:22:51 +0300 Subject: [PATCH] feat(aiturk): phone link works from any network through an end-to-end encrypted tunnel (1.0.0-beta.16) The phone link no longer depends on the local network. The IDE dials out to turkservis.online and bridges tunnel streams to its own TLS proxy on loopback; the phone runs TLS to the pinned IDE certificate inside the tunnel, so the relay only carries ciphertext. The proxy binds to 127.0.0.1 by default and enabling the feature never asks for a firewall rule; direct same-network access is an explicit option. The window shows the real relay, listener and firewall state, and pairing no longer needs local addresses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf --- apps/desktop/electron/main.ts | 26 +- apps/desktop/electron/mobile-host-ui.mjs | 83 +- apps/desktop/electron/mobile-host.css | 10 + apps/desktop/electron/mobile-host.html | 10 +- apps/desktop/electron/mobile-host.mjs | 7 +- .../desktop/electron/mobile-lan-proxy.test.ts | 457 ++++++- apps/desktop/electron/mobile-lan-proxy.ts | 609 ++++++++- .../electron/mobile-relay-client.test.ts | 1141 +++++++++++++++++ apps/desktop/electron/mobile-relay-client.ts | 688 ++++++++++ apps/desktop/package.json | 2 +- package-lock.json | 2 +- 11 files changed, 2905 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/electron/mobile-relay-client.test.ts create mode 100644 apps/desktop/electron/mobile-relay-client.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d9bd9c2..b168b22 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from 'node:child_process' +import { execFile, execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' @@ -32,6 +32,7 @@ import { shell, systemPreferences } from 'electron' +import { WebSocket as RelayWebSocket } from 'ws' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login' @@ -17866,16 +17867,33 @@ function getMobileLink(): MobileLink { return { baseUrl: connection.baseUrl, token: connection.token } }, isRemoteMode: mobileLinkRemoteMode, - firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }), + firewall: createWindowsFirewall({ + spawn, + platform: process.platform, + env: process.env, + // Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the + // console code page; latin1 keeps every byte and the parser needs no translated word. + run: (file, args) => + new Promise(resolve => { + execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => { + const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0 + + resolve({ code, stdout: String(stdout ?? '') }) + }) + }) + }), fetch: (url, init) => fetch(url, init as RequestInit), + // Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy. + WebSocket: RelayWebSocket, 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. + // API traffic is not logged; pairing and refusals are, for diagnosis. Relay + // lines are status changes only ("relay connected", "relay failed:"). log: line => { - if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { + if (/^relay /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) { rememberLog(`[mobile-lan] ${line}`) } } diff --git a/apps/desktop/electron/mobile-host-ui.mjs b/apps/desktop/electron/mobile-host-ui.mjs index c039b31..3586f78 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -6,56 +6,72 @@ 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ı', + intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.', + toggle: 'Telefon bağlantısını kullan', 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ç', + relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı', + relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)', + replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' }, + lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)', + lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)', + lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat', + reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.', + 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ı.', + securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`, + anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', 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ı.', + removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.', + renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.', + renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?', 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.', + no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.', + relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.', 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', + intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.', + toggle: 'Use phone connection', 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', + relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi', + relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)', + replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' }, + lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)', + lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)', + lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off', + reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.', + 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.', + securityCode: code => `Security code: ${code} (the phone must show the same code)`, + anyNetwork: 'The phone and the computer do not have to be on the same network.', 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.', + removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.', + renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.', + renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?', 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).', + no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.', + relay_unavailable: 'The internet cannot be reached, try again.', port_unavailable: 'No free port was found (47321–47340 are in use).', failed: 'That did not work. Try again.', }, @@ -66,7 +82,8 @@ const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementByI 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'], + ['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'], + ['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'], ['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key] let current = null, drawnQr = '', busy = false @@ -83,10 +100,20 @@ function tick() { function render(state) { current = state - $('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled + $('enabled').checked = state.enabled $('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 || '' + // Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried. + const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' } + $('status-box').hidden = !state.enabled || state.remote + const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status + $('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status + $('reissued-box').hidden = !(state.enabled && relay.reissued) + const noRule = lan.direct && lan.firewall === 'missing' + $('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle + : state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff + $('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off' + $('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow + $('lan-disallow').hidden = !lan.direct $('pair-section').hidden = !state.enabled || state.remote $('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing if (state.pairing) { @@ -94,6 +121,8 @@ function render(state) { $('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] + $('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : '' + $('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi tick() } else { drawnQr = ''; $('pair-code').textContent = '' } $('devices-section').hidden = !state.enabled && state.devices.length === 0 @@ -109,9 +138,9 @@ function render(state) { 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' + $('footer').hidden = !state.enabled || state.remote + $('relay-renew').hidden = !relay.ready + $('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed' } async function run(work) { @@ -126,7 +155,9 @@ async function refresh() { try { render(await host.call('state')) } catch (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')) }) +$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) }) +$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) }) +$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) } $('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) diff --git a/apps/desktop/electron/mobile-host.css b/apps/desktop/electron/mobile-host.css index 58b2959..6b9d6f4 100644 --- a/apps/desktop/electron/mobile-host.css +++ b/apps/desktop/electron/mobile-host.css @@ -14,3 +14,13 @@ .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 } +.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 } +.status-row strong { color:var(--text); overflow-wrap:anywhere } +.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) } +.status-row strong[data-state=connected]::before { background:var(--accent) } +.status-row strong[data-state=failed]::before { background:var(--danger) } +.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 } +.status-actions button { margin:0; min-height:44px } +.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere } +footer { display:flex; flex-wrap:wrap; gap:8px 20px } +footer button.link { min-height:44px } diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index 5194367..6ea6abf 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -6,7 +6,12 @@ - + +